Compare commits
10 Commits
external-l
...
@portaljs/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2c1e5b48 | ||
|
|
57952e0817 | ||
|
|
852cf60abc | ||
|
|
704be0d5a7 | ||
|
|
fb3598fa49 | ||
|
|
d898b5a833 | ||
|
|
3aac4dabf9 | ||
|
|
a044f56e3c | ||
|
|
1b58c311eb | ||
|
|
9e73410b17 |
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"nrwl.angular-console",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"firsttris.vscode-jest-runner",
|
|
||||||
"dbaeumer.vscode-eslint"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
77
README.md
77
README.md
@@ -1,31 +1,56 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
🌀 Portal.JS
|
<a href="https://datahub.io/">
|
||||||
<br />
|
<img alt="datahub" src="http://datahub.io/datahub-cube.svg" width="146">
|
||||||
Rapidly build rich data portals using a modern frontend framework
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
* [What is Portal.JS ?](#What-is-Portal.JS)
|
<p align="center">
|
||||||
* [Features](#Features)
|
Bugs, issues and suggestions re DataHub Cloud ☁️ and DataHub OpenSource 🌀
|
||||||
* [For developers](#For-developers)
|
<br />
|
||||||
* [Docs](#Docs)
|
<br /><a href="https://discord.gg/xfFDMPU9dC"><img src="https://dcbadge.vercel.app/api/server/xfFDMPU9dC" /></a>
|
||||||
* [Community](#Community)
|
</p>
|
||||||
* [Appendix](#Appendix)
|
|
||||||
* [What happened to Recline?](#What-happened-to-Recline?)
|
|
||||||
|
|
||||||
# What is Portal.JS
|
## DataHub
|
||||||
|
|
||||||
🌀 Portal.JS is a framework for rapidly building rich data portal frontends using a modern frontend approach. Portal.JS can be used to present a single dataset or build a full-scale data catalog/portal.
|
This repo and issue tracker are for
|
||||||
|
|
||||||
Built in JavaScript and React on top of the popular [Next.js](https://nextjs.com/) framework. Portal.JS assumes a "decoupled" approach where the frontend is a separate service from the backend and interacts with backend(s) via an API. It can be used with any backend and has out of the box support for [CKAN](https://ckan.org/).
|
- DataHub Cloud ☁️ - https://datahub.io/
|
||||||
|
- DataHub 🌀 - https://datahub.io/opensource
|
||||||
|
|
||||||
## Features
|
### Issues
|
||||||
|
|
||||||
|
Found a bug: 👉 https://github.com/datopian/datahub/issues/new
|
||||||
|
|
||||||
|
### Discussions
|
||||||
|
|
||||||
|
Got a suggestion, a question, want some support or just want to shoot the breeze 🙂
|
||||||
|
|
||||||
|
Head to the discussion forum: 👉 https://github.com/datopian/datahub/discussions
|
||||||
|
|
||||||
|
### Chat on Discord
|
||||||
|
|
||||||
|
If you would prefer to get help via live chat check out our discord 👉
|
||||||
|
|
||||||
|
[Discord](https://discord.gg/xfFDMPU9dC)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
https://datahub.io/docs
|
||||||
|
|
||||||
|
## DataHub OpenSource 🌀
|
||||||
|
|
||||||
|
DataHub 🌀 is a platform for rapidly creating rich data portal and publishing systems using a modern frontend approach. Datahub can be used to publish a single dataset or build a full-scale data catalog/portal.
|
||||||
|
|
||||||
|
DataHub is built in JavaScript and React on top of the popular [Next.js](https://nextjs.com/) framework. DataHub assumes a "decoupled" approach where the frontend is a separate service from the backend and interacts with backend(s) via an API. It can be used with any backend and has out of the box support for [CKAN](https://ckan.org/), GitHub, Frictionless Data Packages and more.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
- 🗺️ Unified sites: present data and content in one seamless site, pulling datasets from a DMS (e.g. CKAN) and content from a CMS (e.g. Wordpress) with a common internal API.
|
- 🗺️ Unified sites: present data and content in one seamless site, pulling datasets from a DMS (e.g. CKAN) and content from a CMS (e.g. Wordpress) with a common internal API.
|
||||||
- 👩💻 Developer friendly: built with familiar frontend tech (JavaScript, React, Next.js).
|
- 👩💻 Developer friendly: built with familiar frontend tech (JavaScript, React, Next.js).
|
||||||
- 🔋 Batteries included: full set of portal components out of the box e.g. catalog search, dataset showcase, blog, etc.
|
- 🔋 Batteries included: full set of portal components out of the box e.g. catalog search, dataset showcase, blog, etc.
|
||||||
- 🎨 Easy to theme and customize: installable themes, use standard CSS and React+CSS tooling. Add new routes quickly.
|
- 🎨 Easy to theme and customize: installable themes, use standard CSS and React+CSS tooling. Add new routes quickly.
|
||||||
- 🧱 Extensible: quickly extend and develop/import your own React components
|
- 🧱 Extensible: quickly extend and develop/import your own React components
|
||||||
- 📝 Well documented: full set of documentation plus the documentation of Next.js and Apollo.
|
- 📝 Well documented: full set of documentation plus the documentation of Next.js.
|
||||||
|
|
||||||
### For developers
|
### For developers
|
||||||
|
|
||||||
@@ -33,25 +58,3 @@ Built in JavaScript and React on top of the popular [Next.js](https://nextjs.com
|
|||||||
- 🚀 Next.js framework: so everything in Next.js for free: Server Side Rendering, Static Site Generation, huge number of examples and integrations, etc.
|
- 🚀 Next.js framework: so everything in Next.js for free: Server Side Rendering, Static Site Generation, huge number of examples and integrations, etc.
|
||||||
- Server Side Rendering (SSR) => Unlimited number of pages, SEO and more whilst still using React.
|
- Server Side Rendering (SSR) => Unlimited number of pages, SEO and more whilst still using React.
|
||||||
- Static Site Generation (SSG) => Ultra-simple deployment, great performance, great lighthouse scores and more (good for small sites)
|
- Static Site Generation (SSG) => Ultra-simple deployment, great performance, great lighthouse scores and more (good for small sites)
|
||||||
|
|
||||||
#### **Check out the [Portal.JS website](https://portaljs.org/) for a gallery of live portals**
|
|
||||||
|
|
||||||
___
|
|
||||||
|
|
||||||
# Docs
|
|
||||||
|
|
||||||
Access the Portal.JS documentation at:
|
|
||||||
|
|
||||||
https://portaljs.org/docs
|
|
||||||
|
|
||||||
- [Examples](https://portaljs.org/docs#examples)
|
|
||||||
|
|
||||||
# Community
|
|
||||||
|
|
||||||
If you have questions about anything related to Portal.JS, you're always welcome to ask our community on [GitHub Discussions](https://github.com/datopian/portal.js/discussions) or on our [Discord server](https://discord.gg/EeyfGrGu4U).
|
|
||||||
|
|
||||||
# Appendix
|
|
||||||
|
|
||||||
## What happened to Recline?
|
|
||||||
|
|
||||||
Portal.JS used to be Recline(JS). If you are looking for the old Recline codebase it still exists: see the [`recline` branch](https://github.com/datopian/portal.js/tree/recline). If you want context for the rename see [this issue](https://github.com/datopian/portal.js/issues/520).
|
|
||||||
|
|||||||
3520
package-lock.json
generated
3520
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
# @portaljs/components
|
# @portaljs/components
|
||||||
|
|
||||||
|
## 0.6.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [`a044f56e`](https://github.com/datopian/portaljs/commit/a044f56e3cbe0519ddf9d24d78b0bb7eac917e1c) Thanks [@luccasmmg](https://github.com/luccasmmg)! - Added plotly components
|
||||||
|
|
||||||
## 0.5.10
|
## 0.5.10
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@portaljs/components",
|
"name": "@portaljs/components",
|
||||||
"version": "0.5.10",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "https://portaljs.org",
|
"description": "https://portaljs.org",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -40,11 +40,13 @@
|
|||||||
"ol": "^7.4.0",
|
"ol": "^7.4.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pdfjs-dist": "2.15.349",
|
"pdfjs-dist": "2.15.349",
|
||||||
|
"plotly.js": "^2.30.1",
|
||||||
"postcss-url": "^10.1.3",
|
"postcss-url": "^10.1.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-plotly.js": "^2.6.0",
|
||||||
"react-query": "^3.39.3",
|
"react-query": "^3.39.3",
|
||||||
"react-vega": "^7.6.0",
|
"react-vega": "^7.6.0",
|
||||||
"vega": "5.25.0",
|
"vega": "5.25.0",
|
||||||
|
|||||||
9
packages/components/src/components/Plotly.tsx
Normal file
9
packages/components/src/components/Plotly.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Plot, { PlotParams } from "react-plotly.js";
|
||||||
|
|
||||||
|
export const Plotly: React.FC<PlotParams> = (props) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Plot {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
packages/components/src/components/PlotlyBarChart.tsx
Normal file
157
packages/components/src/components/PlotlyBarChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
|
||||||
|
import { Plotly } from "./Plotly";
|
||||||
|
import Papa, { ParseConfig } from "papaparse";
|
||||||
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
async function getCsv(url: string, bytes: number) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Range: `bytes=0-${bytes}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.text();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseCsv(
|
||||||
|
file: string,
|
||||||
|
parsingConfig: ParseConfig,
|
||||||
|
): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Papa.parse(file, {
|
||||||
|
...parsingConfig,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlotlyBarChartProps {
|
||||||
|
url?: string;
|
||||||
|
data?: { [key: string]: number | string }[];
|
||||||
|
rawCsv?: string;
|
||||||
|
randomId?: number;
|
||||||
|
bytes?: number;
|
||||||
|
parsingConfig?: ParseConfig;
|
||||||
|
xAxis: string;
|
||||||
|
yAxis: string;
|
||||||
|
lineLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlotlyBarChart: React.FC<PlotlyBarChartProps> = ({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
rawCsv,
|
||||||
|
bytes = 5132288,
|
||||||
|
parsingConfig = {},
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
lineLabel,
|
||||||
|
title = "",
|
||||||
|
}) => {
|
||||||
|
const randomId = Math.random();
|
||||||
|
return (
|
||||||
|
// Provide the client to your App
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PlotlyBarChartInner
|
||||||
|
url={url}
|
||||||
|
data={data}
|
||||||
|
rawCsv={rawCsv}
|
||||||
|
randomId={randomId}
|
||||||
|
bytes={bytes}
|
||||||
|
parsingConfig={parsingConfig}
|
||||||
|
xAxis={xAxis}
|
||||||
|
yAxis={yAxis}
|
||||||
|
lineLabel={lineLabel ?? yAxis}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlotlyBarChartInner: React.FC<PlotlyBarChartProps> = ({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
rawCsv,
|
||||||
|
randomId,
|
||||||
|
bytes,
|
||||||
|
parsingConfig,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
lineLabel,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
if (data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ height: "500px" }}>
|
||||||
|
<Plotly
|
||||||
|
layout={{
|
||||||
|
title,
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
x: data.map((d) => d[xAxis]),
|
||||||
|
y: data.map((d) => d[yAxis]),
|
||||||
|
type: "bar",
|
||||||
|
name: lineLabel,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
|
||||||
|
["dataCsv", url, randomId],
|
||||||
|
() => getCsv(url as string, bytes ?? 5132288),
|
||||||
|
{ enabled: !!url },
|
||||||
|
);
|
||||||
|
const { data: parsedData, isLoading: isParsing } = useQuery(
|
||||||
|
["dataPreview", csvString, randomId],
|
||||||
|
() =>
|
||||||
|
parseCsv(
|
||||||
|
rawCsv ? (rawCsv as string) : (csvString as string),
|
||||||
|
parsingConfig ?? {},
|
||||||
|
),
|
||||||
|
{ enabled: rawCsv ? true : !!csvString },
|
||||||
|
);
|
||||||
|
if (isParsing || isDownloadingCSV)
|
||||||
|
<div className="w-full flex justify-center items-center h-[500px]">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>;
|
||||||
|
if (parsedData)
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ height: "500px" }}>
|
||||||
|
<Plotly
|
||||||
|
layout={{
|
||||||
|
title,
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
x: parsedData.data.map((d: any) => d[xAxis]),
|
||||||
|
y: parsedData.data.map((d: any) => d[yAxis]),
|
||||||
|
type: "bar",
|
||||||
|
name: lineLabel,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center items-center h-[500px]">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
packages/components/src/components/PlotlyLineChart.tsx
Normal file
157
packages/components/src/components/PlotlyLineChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
|
||||||
|
import { Plotly } from "./Plotly";
|
||||||
|
import Papa, { ParseConfig } from "papaparse";
|
||||||
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
async function getCsv(url: string, bytes: number) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Range: `bytes=0-${bytes}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await response.text();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseCsv(
|
||||||
|
file: string,
|
||||||
|
parsingConfig: ParseConfig,
|
||||||
|
): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Papa.parse(file, {
|
||||||
|
...parsingConfig,
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlotlyLineChartProps {
|
||||||
|
url?: string;
|
||||||
|
data?: { [key: string]: number | string }[];
|
||||||
|
rawCsv?: string;
|
||||||
|
randomId?: number;
|
||||||
|
bytes?: number;
|
||||||
|
parsingConfig?: ParseConfig;
|
||||||
|
xAxis: string;
|
||||||
|
yAxis: string;
|
||||||
|
lineLabel?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlotlyLineChart: React.FC<PlotlyLineChartProps> = ({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
rawCsv,
|
||||||
|
bytes = 5132288,
|
||||||
|
parsingConfig = {},
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
lineLabel,
|
||||||
|
title = "",
|
||||||
|
}) => {
|
||||||
|
const randomId = Math.random();
|
||||||
|
return (
|
||||||
|
// Provide the client to your App
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LineChartInner
|
||||||
|
url={url}
|
||||||
|
data={data}
|
||||||
|
rawCsv={rawCsv}
|
||||||
|
randomId={randomId}
|
||||||
|
bytes={bytes}
|
||||||
|
parsingConfig={parsingConfig}
|
||||||
|
xAxis={xAxis}
|
||||||
|
yAxis={yAxis}
|
||||||
|
lineLabel={lineLabel ?? yAxis}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LineChartInner: React.FC<PlotlyLineChartProps> = ({
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
rawCsv,
|
||||||
|
randomId,
|
||||||
|
bytes,
|
||||||
|
parsingConfig,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
lineLabel,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
if (data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ height: "500px" }}>
|
||||||
|
<Plotly
|
||||||
|
layout={{
|
||||||
|
title,
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
x: data.map((d) => d[xAxis]),
|
||||||
|
y: data.map((d) => d[yAxis]),
|
||||||
|
mode: "lines",
|
||||||
|
name: lineLabel,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
|
||||||
|
["dataCsv", url, randomId],
|
||||||
|
() => getCsv(url as string, bytes ?? 5132288),
|
||||||
|
{ enabled: !!url },
|
||||||
|
);
|
||||||
|
const { data: parsedData, isLoading: isParsing } = useQuery(
|
||||||
|
["dataPreview", csvString, randomId],
|
||||||
|
() =>
|
||||||
|
parseCsv(
|
||||||
|
rawCsv ? (rawCsv as string) : (csvString as string),
|
||||||
|
parsingConfig ?? {},
|
||||||
|
),
|
||||||
|
{ enabled: rawCsv ? true : !!csvString },
|
||||||
|
);
|
||||||
|
if (isParsing || isDownloadingCSV)
|
||||||
|
<div className="w-full flex justify-center items-center h-[500px]">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>;
|
||||||
|
if (parsedData)
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ height: "500px" }}>
|
||||||
|
<Plotly
|
||||||
|
layout={{
|
||||||
|
title,
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
x: parsedData.data.map((d: any) => d[xAxis]),
|
||||||
|
y: parsedData.data.map((d: any) => d[yAxis]),
|
||||||
|
mode: "lines",
|
||||||
|
name: lineLabel,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="w-full flex justify-center items-center h-[500px]">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,3 +10,6 @@ export * from './components/PdfViewer';
|
|||||||
export * from "./components/Excel";
|
export * from "./components/Excel";
|
||||||
export * from "./components/BucketViewer";
|
export * from "./components/BucketViewer";
|
||||||
export * from "./components/Iframe";
|
export * from "./components/Iframe";
|
||||||
|
export * from "./components/Plotly";
|
||||||
|
export * from "./components/PlotlyLineChart";
|
||||||
|
export * from "./components/PlotlyBarChart";
|
||||||
|
|||||||
74
packages/components/stories/BarChartPlotly.stories.ts
Normal file
74
packages/components/stories/BarChartPlotly.stories.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { PlotlyBarChart, PlotlyBarChartProps } from '../src/components/PlotlyBarChart';
|
||||||
|
|
||||||
|
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Components/PlotlyBarChart',
|
||||||
|
component: PlotlyBarChart,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
url: {
|
||||||
|
description:
|
||||||
|
'CSV Url to be parsed and used as data source',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
description:
|
||||||
|
'Data to be displayed. as an array of key value pairs \n\n E.g.: [{ year: 1850, temperature: -0.41765878 }, { year: 1851, temperature: -0.2333498 }, ...]',
|
||||||
|
},
|
||||||
|
rawCsv: {
|
||||||
|
description:
|
||||||
|
'Raw csv data to be parsed and used as data source',
|
||||||
|
},
|
||||||
|
bytes: {
|
||||||
|
description:
|
||||||
|
'How many bytes to read from the url',
|
||||||
|
},
|
||||||
|
parsingConfig: {
|
||||||
|
description: 'If using url or rawCsv, this parsing config will be used to parse the data. Optional, check https://www.papaparse.com/ for more info',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
description: 'Title to display on the chart. Optional.',
|
||||||
|
},
|
||||||
|
lineLabel: {
|
||||||
|
description: 'Label to display on the line, Optional, will use yAxis if not provided',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
description:
|
||||||
|
'Name of the X axis on the data. Required when the "data" parameter is an URL.',
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
description:
|
||||||
|
'Name of the Y axis on the data. Required when the "data" parameter is an URL.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<PlotlyBarChartProps>;
|
||||||
|
|
||||||
|
export const FromDataPoints: Story = {
|
||||||
|
name: 'Line chart from array of data points',
|
||||||
|
args: {
|
||||||
|
data: [
|
||||||
|
{year: '1850', temperature: -0.41765878},
|
||||||
|
{year: '1851', temperature: -0.2333498},
|
||||||
|
{year: '1852', temperature: -0.22939907},
|
||||||
|
{year: '1853', temperature: -0.27035445},
|
||||||
|
{year: '1854', temperature: -0.29163003},
|
||||||
|
],
|
||||||
|
xAxis: 'year',
|
||||||
|
yAxis: 'temperature',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FromURL: Story = {
|
||||||
|
name: 'Line chart from URL',
|
||||||
|
args: {
|
||||||
|
title: 'Apple Stock Prices',
|
||||||
|
url: 'https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv',
|
||||||
|
xAxis: 'Date',
|
||||||
|
yAxis: 'AAPL.Open',
|
||||||
|
},
|
||||||
|
};
|
||||||
74
packages/components/stories/LineChartPlotly.stories.ts
Normal file
74
packages/components/stories/LineChartPlotly.stories.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { PlotlyLineChart, PlotlyLineChartProps } from '../src/components/PlotlyLineChart';
|
||||||
|
|
||||||
|
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Components/PlotlyLineChart',
|
||||||
|
component: PlotlyLineChart,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
url: {
|
||||||
|
description:
|
||||||
|
'CSV Url to be parsed and used as data source',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
description:
|
||||||
|
'Data to be displayed. as an array of key value pairs \n\n E.g.: [{ year: 1850, temperature: -0.41765878 }, { year: 1851, temperature: -0.2333498 }, ...]',
|
||||||
|
},
|
||||||
|
rawCsv: {
|
||||||
|
description:
|
||||||
|
'Raw csv data to be parsed and used as data source',
|
||||||
|
},
|
||||||
|
bytes: {
|
||||||
|
description:
|
||||||
|
'How many bytes to read from the url',
|
||||||
|
},
|
||||||
|
parsingConfig: {
|
||||||
|
description: 'If using url or rawCsv, this parsing config will be used to parse the data. Optional, check https://www.papaparse.com/ for more info',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
description: 'Title to display on the chart. Optional.',
|
||||||
|
},
|
||||||
|
lineLabel: {
|
||||||
|
description: 'Label to display on the line, Optional, will use yAxis if not provided',
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
description:
|
||||||
|
'Name of the X axis on the data. Required when the "data" parameter is an URL.',
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
description:
|
||||||
|
'Name of the Y axis on the data. Required when the "data" parameter is an URL.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<PlotlyLineChartProps>;
|
||||||
|
|
||||||
|
export const FromDataPoints: Story = {
|
||||||
|
name: 'Line chart from array of data points',
|
||||||
|
args: {
|
||||||
|
data: [
|
||||||
|
{year: '1850', temperature: -0.41765878},
|
||||||
|
{year: '1851', temperature: -0.2333498},
|
||||||
|
{year: '1852', temperature: -0.22939907},
|
||||||
|
{year: '1853', temperature: -0.27035445},
|
||||||
|
{year: '1854', temperature: -0.29163003},
|
||||||
|
],
|
||||||
|
xAxis: 'year',
|
||||||
|
yAxis: 'temperature',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FromURL: Story = {
|
||||||
|
name: 'Line chart from URL',
|
||||||
|
args: {
|
||||||
|
title: 'Oil Price x Year',
|
||||||
|
url: 'https://raw.githubusercontent.com/datasets/oil-prices/main/data/wti-year.csv',
|
||||||
|
xAxis: 'Date',
|
||||||
|
yAxis: 'Price',
|
||||||
|
},
|
||||||
|
};
|
||||||
39
packages/components/stories/Plotly.stories.ts
Normal file
39
packages/components/stories/Plotly.stories.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { Plotly } from '../src/components/Plotly';
|
||||||
|
|
||||||
|
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Components/Plotly',
|
||||||
|
component: Plotly,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<any>;
|
||||||
|
|
||||||
|
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
|
||||||
|
export const Primary: Story = {
|
||||||
|
name: 'Chart built with Plotly',
|
||||||
|
args: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
x: [1, 2, 3],
|
||||||
|
y: [2, 6, 3],
|
||||||
|
type: 'scatter',
|
||||||
|
mode: 'lines+markers',
|
||||||
|
marker: { color: 'red' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: {
|
||||||
|
title: 'Chart built with Plotly',
|
||||||
|
xaxis: {
|
||||||
|
title: 'x Axis',
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
title: 'y Axis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -46,8 +46,8 @@ export const SiteToc: React.FC<Props> = ({ currentPath, nav }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav data-testid="lhs-sidebar" className="flex flex-col space-y-3 text-sm">
|
<nav data-testid="lhs-sidebar" className="flex flex-col space-y-3 text-sm">
|
||||||
{sortNavGroupChildren(nav).map((n) => (
|
{sortNavGroupChildren(nav).map((n, index) => (
|
||||||
<NavComponent item={n} isActive={false} />
|
<NavComponent key={index} item={n} isActive={false} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@@ -96,8 +96,8 @@ const NavComponent: React.FC<{
|
|||||||
leaveTo="transform scale-95 opacity-0"
|
leaveTo="transform scale-95 opacity-0"
|
||||||
>
|
>
|
||||||
<Disclosure.Panel className="flex flex-col space-y-3 pl-5 mt-3">
|
<Disclosure.Panel className="flex flex-col space-y-3 pl-5 mt-3">
|
||||||
{sortNavGroupChildren(item.children).map((subItem) => (
|
{sortNavGroupChildren(item.children).map((subItem, index) => (
|
||||||
<NavComponent item={subItem} isActive={false} />
|
<NavComponent key={index} item={subItem} isActive={false} />
|
||||||
))}
|
))}
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @portaljs/remark-wiki-link
|
# @portaljs/remark-wiki-link
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#1084](https://github.com/datopian/datahub/pull/1084) [`57952e08`](https://github.com/datopian/datahub/commit/57952e0817770138881e7492dc9f43e9910b56a8) Thanks [@mohamedsalem401](https://github.com/mohamedsalem401)! - Add image resize feature
|
||||||
|
|
||||||
## 1.1.2
|
## 1.1.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@portaljs/remark-wiki-link",
|
"name": "@portaljs/remark-wiki-link",
|
||||||
"version": "1.1.2",
|
"version": "1.2.0",
|
||||||
"description": "Parse and render wiki-style links in markdown especially Obsidian style links.",
|
"description": "Parse and render wiki-style links in markdown especially Obsidian style links.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { isSupportedFileFormat } from "./isSupportedFileFormat";
|
import { isSupportedFileFormat } from './isSupportedFileFormat';
|
||||||
|
|
||||||
const defaultWikiLinkResolver = (target: string) => {
|
const defaultWikiLinkResolver = (target: string) => {
|
||||||
// for [[#heading]] links
|
// for [[#heading]] links
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
let permalink = target.replace(/\/index$/, "");
|
let permalink = target.replace(/\/index$/, '');
|
||||||
// TODO what to do with [[index]] link?
|
// TODO what to do with [[index]] link?
|
||||||
if (permalink.length === 0) {
|
if (permalink.length === 0) {
|
||||||
permalink = "/";
|
permalink = '/';
|
||||||
}
|
}
|
||||||
return [permalink];
|
return [permalink];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FromMarkdownOptions {
|
export interface FromMarkdownOptions {
|
||||||
pathFormat?:
|
pathFormat?:
|
||||||
| "raw" // default; use for regular relative or absolute paths
|
| 'raw' // default; use for regular relative or absolute paths
|
||||||
| "obsidian-absolute" // use for Obsidian-style absolute paths (with no leading slash)
|
| 'obsidian-absolute' // use for Obsidian-style absolute paths (with no leading slash)
|
||||||
| "obsidian-short"; // use for Obsidian-style shortened paths (shortest path possible)
|
| 'obsidian-short'; // use for Obsidian-style shortened paths (shortest path possible)
|
||||||
permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against
|
permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against
|
||||||
wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks
|
wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks
|
||||||
newClassName?: string; // class name to add to links that don't have a matching permalink
|
newClassName?: string; // class name to add to links that don't have a matching permalink
|
||||||
@@ -25,14 +25,23 @@ export interface FromMarkdownOptions {
|
|||||||
hrefTemplate?: (permalink: string) => string; // function to generate the href attribute of a link
|
hrefTemplate?: (permalink: string) => string; // function to generate the href attribute of a link
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getImageSize(size: string) {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [width, height] = size.split('x');
|
||||||
|
|
||||||
|
if (!height) height = width;
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
// mdas-util-from-markdown extension
|
// mdas-util-from-markdown extension
|
||||||
// https://github.com/syntax-tree/mdast-util-from-markdown#extension
|
// https://github.com/syntax-tree/mdast-util-from-markdown#extension
|
||||||
function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
||||||
const pathFormat = opts.pathFormat || "raw";
|
const pathFormat = opts.pathFormat || 'raw';
|
||||||
const permalinks = opts.permalinks || [];
|
const permalinks = opts.permalinks || [];
|
||||||
const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver;
|
const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver;
|
||||||
const newClassName = opts.newClassName || "new";
|
const newClassName = opts.newClassName || 'new';
|
||||||
const wikiLinkClassName = opts.wikiLinkClassName || "internal";
|
const wikiLinkClassName = opts.wikiLinkClassName || 'internal';
|
||||||
const defaultHrefTemplate = (permalink: string) => permalink;
|
const defaultHrefTemplate = (permalink: string) => permalink;
|
||||||
|
|
||||||
const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate;
|
const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate;
|
||||||
@@ -44,9 +53,9 @@ function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
|||||||
function enterWikiLink(token) {
|
function enterWikiLink(token) {
|
||||||
this.enter(
|
this.enter(
|
||||||
{
|
{
|
||||||
type: "wikiLink",
|
type: 'wikiLink',
|
||||||
data: {
|
data: {
|
||||||
isEmbed: token.isType === "embed",
|
isEmbed: token.isType === 'embed',
|
||||||
target: null, // the target of the link, e.g. "Foo Bar#Heading" in "[[Foo Bar#Heading]]"
|
target: null, // the target of the link, e.g. "Foo Bar#Heading" in "[[Foo Bar#Heading]]"
|
||||||
alias: null, // the alias of the link, e.g. "Foo" in "[[Foo Bar|Foo]]"
|
alias: null, // the alias of the link, e.g. "Foo" in "[[Foo Bar|Foo]]"
|
||||||
permalink: null, // TODO shouldn't this be named just "link"?
|
permalink: null, // TODO shouldn't this be named just "link"?
|
||||||
@@ -80,18 +89,18 @@ function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
|||||||
} = wikiLink;
|
} = wikiLink;
|
||||||
// eslint-disable-next-line no-useless-escape
|
// eslint-disable-next-line no-useless-escape
|
||||||
const wikiLinkWithHeadingPattern = /^(.*?)(#.*)?$/u;
|
const wikiLinkWithHeadingPattern = /^(.*?)(#.*)?$/u;
|
||||||
const [, path, heading = ""] = target.match(wikiLinkWithHeadingPattern);
|
const [, path, heading = ''] = target.match(wikiLinkWithHeadingPattern);
|
||||||
|
|
||||||
const possibleWikiLinkPermalinks = wikiLinkResolver(path);
|
const possibleWikiLinkPermalinks = wikiLinkResolver(path);
|
||||||
|
|
||||||
const matchingPermalink = permalinks.find((e) => {
|
const matchingPermalink = permalinks.find((e) => {
|
||||||
return possibleWikiLinkPermalinks.find((p) => {
|
return possibleWikiLinkPermalinks.find((p) => {
|
||||||
if (pathFormat === "obsidian-short") {
|
if (pathFormat === 'obsidian-short') {
|
||||||
if (e === p || e.endsWith(p)) {
|
if (e === p || e.endsWith(p)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (pathFormat === "obsidian-absolute") {
|
} else if (pathFormat === 'obsidian-absolute') {
|
||||||
if (e === "/" + p) {
|
if (e === '/' + p) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -106,21 +115,19 @@ function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
|||||||
// TODO this is ugly
|
// TODO this is ugly
|
||||||
const link =
|
const link =
|
||||||
matchingPermalink ||
|
matchingPermalink ||
|
||||||
(pathFormat === "obsidian-absolute"
|
(pathFormat === 'obsidian-absolute'
|
||||||
? "/" + possibleWikiLinkPermalinks[0]
|
? '/' + possibleWikiLinkPermalinks[0]
|
||||||
: possibleWikiLinkPermalinks[0]) ||
|
: possibleWikiLinkPermalinks[0]) ||
|
||||||
"";
|
'';
|
||||||
const isExternal = /^https?:\/\//.test(link);
|
|
||||||
|
|
||||||
wikiLink.data.exists = !!matchingPermalink;
|
wikiLink.data.exists = !!matchingPermalink;
|
||||||
wikiLink.data.permalink = link;
|
wikiLink.data.permalink = link;
|
||||||
|
|
||||||
// remove leading # if the target is a heading on the same page
|
// remove leading # if the target is a heading on the same page
|
||||||
const displayName = alias || target.replace(/^#/, "");
|
const displayName = alias || target.replace(/^#/, '');
|
||||||
const headingId = heading.replace(/\s+/g, "-").toLowerCase();
|
const headingId = heading.replace(/\s+/g, '-').toLowerCase();
|
||||||
let classNames = wikiLinkClassName;
|
let classNames = wikiLinkClassName;
|
||||||
if (!matchingPermalink) {
|
if (!matchingPermalink) {
|
||||||
classNames += " " + newClassName;
|
classNames += ' ' + newClassName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEmbed) {
|
if (isEmbed) {
|
||||||
@@ -128,48 +135,55 @@ function fromMarkdown(opts: FromMarkdownOptions = {}) {
|
|||||||
if (!isSupportedFormat) {
|
if (!isSupportedFormat) {
|
||||||
// Temporarily render note transclusion as a regular wiki link
|
// Temporarily render note transclusion as a regular wiki link
|
||||||
if (!format) {
|
if (!format) {
|
||||||
wikiLink.data.hName = "a";
|
wikiLink.data.hName = 'a';
|
||||||
wikiLink.data.hProperties = {
|
wikiLink.data.hProperties = {
|
||||||
className: classNames + " " + "transclusion",
|
className: classNames + ' ' + 'transclusion',
|
||||||
href: hrefTemplate(link) + headingId
|
href: hrefTemplate(link) + headingId,
|
||||||
};
|
};
|
||||||
wikiLink.data.hChildren = [{ type: "text", value: displayName }];
|
wikiLink.data.hChildren = [{ type: 'text', value: displayName }];
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
wikiLink.data.hName = "p";
|
wikiLink.data.hName = 'p';
|
||||||
wikiLink.data.hChildren = [
|
wikiLink.data.hChildren = [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: 'text',
|
||||||
value: `![[${target}]]`,
|
value: `![[${target}]]`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else if (format === "pdf") {
|
} else if (format === 'pdf') {
|
||||||
wikiLink.data.hName = "iframe";
|
wikiLink.data.hName = 'iframe';
|
||||||
wikiLink.data.hProperties = {
|
wikiLink.data.hProperties = {
|
||||||
className: classNames,
|
className: classNames,
|
||||||
width: "100%",
|
width: '100%',
|
||||||
src: `${hrefTemplate(link)}#toolbar=0`,
|
src: `${hrefTemplate(link)}#toolbar=0`,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
wikiLink.data.hName = "img";
|
const hasDimensions = alias && /^\d+(x\d+)?$/.test(alias);
|
||||||
|
// Take the target as alt text except if alt name was provided [[target|alt text]]
|
||||||
|
const altText = hasDimensions || !alias ? target : alias;
|
||||||
|
|
||||||
|
wikiLink.data.hName = 'img';
|
||||||
wikiLink.data.hProperties = {
|
wikiLink.data.hProperties = {
|
||||||
className: classNames,
|
className: classNames,
|
||||||
src: hrefTemplate(link),
|
src: hrefTemplate(link),
|
||||||
alt: displayName,
|
alt: altText
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hasDimensions) {
|
||||||
|
const { width, height } = getImageSize(alias as string);
|
||||||
|
Object.assign(wikiLink.data.hProperties, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
wikiLink.data.hName = "a";
|
wikiLink.data.hName = 'a';
|
||||||
wikiLink.data.hProperties = {
|
wikiLink.data.hProperties = {
|
||||||
className: classNames,
|
className: classNames,
|
||||||
href: hrefTemplate(link) + headingId,
|
href: hrefTemplate(link) + headingId,
|
||||||
target: "_blank",
|
|
||||||
};
|
};
|
||||||
if(isExternal){
|
wikiLink.data.hChildren = [{ type: 'text', value: displayName }];
|
||||||
wikiLink.data.hProperties.target = "_blank"; // Open in a new tab
|
|
||||||
}
|
|
||||||
wikiLink.data.hChildren = [{ type: "text", value: displayName }];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { isSupportedFileFormat } from "./isSupportedFileFormat";
|
import { getImageSize } from './fromMarkdown';
|
||||||
|
import { isSupportedFileFormat } from './isSupportedFileFormat';
|
||||||
|
|
||||||
const defaultWikiLinkResolver = (target: string) => {
|
const defaultWikiLinkResolver = (target: string) => {
|
||||||
// for [[#heading]] links
|
// for [[#heading]] links
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
let permalink = target.replace(/\/index$/, "");
|
let permalink = target.replace(/\/index$/, '');
|
||||||
// TODO what to do with [[index]] link?
|
// TODO what to do with [[index]] link?
|
||||||
if (permalink.length === 0) {
|
if (permalink.length === 0) {
|
||||||
permalink = "/";
|
permalink = '/';
|
||||||
}
|
}
|
||||||
return [permalink];
|
return [permalink];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface HtmlOptions {
|
export interface HtmlOptions {
|
||||||
pathFormat?:
|
pathFormat?:
|
||||||
| "raw" // default; use for regular relative or absolute paths
|
| 'raw' // default; use for regular relative or absolute paths
|
||||||
| "obsidian-absolute" // use for Obsidian-style absolute paths (with no leading slash)
|
| 'obsidian-absolute' // use for Obsidian-style absolute paths (with no leading slash)
|
||||||
| "obsidian-short"; // use for Obsidian-style shortened paths (shortest path possible)
|
| 'obsidian-short'; // use for Obsidian-style shortened paths (shortest path possible)
|
||||||
permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against
|
permalinks?: string[]; // list of permalinks to match possible permalinks of a wiki link against
|
||||||
wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks
|
wikiLinkResolver?: (name: string) => string[]; // function to resolve wiki links to an array of possible permalinks
|
||||||
newClassName?: string; // class name to add to links that don't have a matching permalink
|
newClassName?: string; // class name to add to links that don't have a matching permalink
|
||||||
@@ -28,11 +29,11 @@ export interface HtmlOptions {
|
|||||||
// Micromark HtmlExtension
|
// Micromark HtmlExtension
|
||||||
// https://github.com/micromark/micromark#htmlextension
|
// https://github.com/micromark/micromark#htmlextension
|
||||||
function html(opts: HtmlOptions = {}) {
|
function html(opts: HtmlOptions = {}) {
|
||||||
const pathFormat = opts.pathFormat || "raw";
|
const pathFormat = opts.pathFormat || 'raw';
|
||||||
const permalinks = opts.permalinks || [];
|
const permalinks = opts.permalinks || [];
|
||||||
const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver;
|
const wikiLinkResolver = opts.wikiLinkResolver || defaultWikiLinkResolver;
|
||||||
const newClassName = opts.newClassName || "new";
|
const newClassName = opts.newClassName || 'new';
|
||||||
const wikiLinkClassName = opts.wikiLinkClassName || "internal";
|
const wikiLinkClassName = opts.wikiLinkClassName || 'internal';
|
||||||
const defaultHrefTemplate = (permalink: string) => permalink;
|
const defaultHrefTemplate = (permalink: string) => permalink;
|
||||||
const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate;
|
const hrefTemplate = opts.hrefTemplate || defaultHrefTemplate;
|
||||||
|
|
||||||
@@ -41,21 +42,21 @@ function html(opts: HtmlOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enterWikiLink() {
|
function enterWikiLink() {
|
||||||
let stack = this.getData("wikiLinkStack");
|
let stack = this.getData('wikiLinkStack');
|
||||||
if (!stack) this.setData("wikiLinkStack", (stack = []));
|
if (!stack) this.setData('wikiLinkStack', (stack = []));
|
||||||
|
|
||||||
stack.push({});
|
stack.push({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitWikiLinkTarget(token) {
|
function exitWikiLinkTarget(token) {
|
||||||
const target = this.sliceSerialize(token);
|
const target = this.sliceSerialize(token);
|
||||||
const current = top(this.getData("wikiLinkStack"));
|
const current = top(this.getData('wikiLinkStack'));
|
||||||
current.target = target;
|
current.target = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitWikiLinkAlias(token) {
|
function exitWikiLinkAlias(token) {
|
||||||
const alias = this.sliceSerialize(token);
|
const alias = this.sliceSerialize(token);
|
||||||
const current = top(this.getData("wikiLinkStack"));
|
const current = top(this.getData('wikiLinkStack'));
|
||||||
current.alias = alias;
|
current.alias = alias;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +97,6 @@ function html(opts: HtmlOptions = {}) {
|
|||||||
: possibleWikiLinkPermalinks[0]) ||
|
: possibleWikiLinkPermalinks[0]) ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const isExternal = /^https?:\/\//.test(link);
|
|
||||||
const openInNewTab = isExternal ? 'target="_blank"' : '';
|
|
||||||
|
|
||||||
// remove leading # if the target is a heading on the same page
|
// remove leading # if the target is a heading on the same page
|
||||||
const displayName = alias || target.replace(/^#/, "");
|
const displayName = alias || target.replace(/^#/, "");
|
||||||
// replace spaces with dashes and lowercase headings
|
// replace spaces with dashes and lowercase headings
|
||||||
@@ -114,7 +112,9 @@ function html(opts: HtmlOptions = {}) {
|
|||||||
// Temporarily render note transclusion as a regular wiki link
|
// Temporarily render note transclusion as a regular wiki link
|
||||||
if (!format) {
|
if (!format) {
|
||||||
this.tag(
|
this.tag(
|
||||||
`<a href="${hrefTemplate(link + headingId)}" class="${classNames} transclusion">`
|
`<a href="${hrefTemplate(
|
||||||
|
link + headingId
|
||||||
|
)}" class="${classNames} transclusion">`
|
||||||
);
|
);
|
||||||
this.raw(displayName);
|
this.raw(displayName);
|
||||||
this.tag("</a>");
|
this.tag("</a>");
|
||||||
@@ -128,11 +128,18 @@ function html(opts: HtmlOptions = {}) {
|
|||||||
)}#toolbar=0" class="${classNames}" />`
|
)}#toolbar=0" class="${classNames}" />`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.tag(
|
const hasDimensions = alias && /^\d+(x\d+)?$/.test(alias);
|
||||||
`<img src="${hrefTemplate(
|
// Take the target as alt text except if alt name was provided [[target|alt text]]
|
||||||
|
const altText = hasDimensions || !alias ? target : alias;
|
||||||
|
let imgAttributes = `src="${hrefTemplate(
|
||||||
link
|
link
|
||||||
)}" alt="${displayName}" class="${classNames}" />`
|
)}" alt="${altText}" class="${classNames}"`;
|
||||||
);
|
|
||||||
|
if (hasDimensions) {
|
||||||
|
const { width, height } = getImageSize(alias as string);
|
||||||
|
imgAttributes += ` width="${width}" height="${height}"`;
|
||||||
|
}
|
||||||
|
this.tag(`<img ${imgAttributes} />`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.tag(
|
this.tag(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
html({
|
html({
|
||||||
permalinks: ["/some/folder/Wiki Link"],
|
permalinks: ["/some/folder/Wiki Link"],
|
||||||
pathFormat: "obsidian-short",
|
pathFormat: "obsidian-short",
|
||||||
}) as any // TODO type fix
|
}) as any, // TODO type fix
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
@@ -75,7 +75,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
html({
|
html({
|
||||||
permalinks: ["/some/folder/Wiki Link"],
|
permalinks: ["/some/folder/Wiki Link"],
|
||||||
pathFormat: "obsidian-absolute",
|
pathFormat: "obsidian-absolute",
|
||||||
}) as any // TODO type fix
|
}) as any, // TODO type fix
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
@@ -97,10 +97,14 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("parses a wiki link with heading and alias", () => {
|
test("parses a wiki link with heading and alias", () => {
|
||||||
const serialized = micromark("[[Wiki Link#Some Heading|Alias]]", "ascii", {
|
const serialized = micromark(
|
||||||
|
"[[Wiki Link#Some Heading|Alias]]",
|
||||||
|
"ascii",
|
||||||
|
{
|
||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
}
|
||||||
|
);
|
||||||
// note: lowercased and hyphenated heading
|
// note: lowercased and hyphenated heading
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
'<p><a href="Wiki Link#some-heading" class="internal new">Alias</a></p>'
|
'<p><a href="Wiki Link#some-heading" class="internal new">Alias</a></p>'
|
||||||
@@ -134,7 +138,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
});
|
||||||
expect(serialized).toBe("<p>![[My Image.xyz]]</p>");
|
expect(serialized).toBe('<p>![[My Image.xyz]]</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses and image ambed with a matching permalink", () => {
|
test("parses and image ambed with a matching permalink", () => {
|
||||||
@@ -147,6 +151,28 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: Fix alt attribute
|
||||||
|
test("Can identify the dimensions of the image if exists", () => {
|
||||||
|
const serialized = micromark("![[My Image.jpg|200]]", "ascii", {
|
||||||
|
extensions: [syntax()],
|
||||||
|
htmlExtensions: [html({ permalinks: ["My Image.jpg"] }) as any], // TODO type fix
|
||||||
|
});
|
||||||
|
expect(serialized).toBe(
|
||||||
|
'<p><img src="My Image.jpg" alt="My Image.jpg" class="internal" width="200" height="200" /></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Fix alt attribute
|
||||||
|
test("Can identify the dimensions of the image if exists", () => {
|
||||||
|
const serialized = micromark("![[My Image.jpg|200x200]]", "ascii", {
|
||||||
|
extensions: [syntax()],
|
||||||
|
htmlExtensions: [html({ permalinks: ["My Image.jpg"] }) as any], // TODO type fix
|
||||||
|
});
|
||||||
|
expect(serialized).toBe(
|
||||||
|
'<p><img src="My Image.jpg" alt="My Image.jpg" class="internal" width="200" height="200" /></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("parses an image embed with a matching permalink and Obsidian-style shortedned path", () => {
|
test("parses an image embed with a matching permalink and Obsidian-style shortedned path", () => {
|
||||||
const serialized = micromark("![[My Image.jpg]]", {
|
const serialized = micromark("![[My Image.jpg]]", {
|
||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
@@ -154,7 +180,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
html({
|
html({
|
||||||
permalinks: ["/assets/My Image.jpg"],
|
permalinks: ["/assets/My Image.jpg"],
|
||||||
pathFormat: "obsidian-short",
|
pathFormat: "obsidian-short",
|
||||||
}) as any // TODO type fix
|
}) as any, // TODO type fix
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
@@ -189,7 +215,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
});
|
||||||
expect(serialized).toBe("<p>[[Wiki Link</p>");
|
expect(serialized).toBe('<p>[[Wiki Link</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't parse a wiki link with one missing closing bracket", () => {
|
test("doesn't parse a wiki link with one missing closing bracket", () => {
|
||||||
@@ -197,7 +223,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
});
|
||||||
expect(serialized).toBe("<p>[[Wiki Link]</p>");
|
expect(serialized).toBe('<p>[[Wiki Link]</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't parse a wiki link with a missing opening bracket", () => {
|
test("doesn't parse a wiki link with a missing opening bracket", () => {
|
||||||
@@ -205,7 +231,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
});
|
||||||
expect(serialized).toBe("<p>[Wiki Link]]</p>");
|
expect(serialized).toBe('<p>[Wiki Link]]</p>');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't parse a wiki link in single brackets", () => {
|
test("doesn't parse a wiki link in single brackets", () => {
|
||||||
@@ -213,7 +239,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
extensions: [syntax()],
|
extensions: [syntax()],
|
||||||
htmlExtensions: [html() as any], // TODO type fix
|
htmlExtensions: [html() as any], // TODO type fix
|
||||||
});
|
});
|
||||||
expect(serialized).toBe("<p>[Wiki Link]</p>");
|
expect(serialized).toBe('<p>[Wiki Link]</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,7 +251,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
html({
|
html({
|
||||||
newClassName: "test-new",
|
newClassName: "test-new",
|
||||||
wikiLinkClassName: "test-wiki-link",
|
wikiLinkClassName: "test-wiki-link",
|
||||||
}) as any // TODO type fix
|
}) as any, // TODO type fix
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
@@ -251,7 +277,7 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
wikiLinkResolver: (page) => [
|
wikiLinkResolver: (page) => [
|
||||||
page.replace(/\s+/, "-").toLowerCase(),
|
page.replace(/\s+/, "-").toLowerCase(),
|
||||||
],
|
],
|
||||||
}) as any // TODO type fix
|
}) as any, // TODO type fix
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(
|
expect(serialized).toBe(
|
||||||
@@ -330,15 +356,5 @@ describe("micromark-extension-wiki-link", () => {
|
|||||||
});
|
});
|
||||||
expect(serialized).toBe(`<p><a href="li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô-íã_a(n)d_underline!:ª%@'*º$-°~./\\" class="internal new">li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#LI NK-W(i)th-àcèô íã_a(n)d_uNdErlinE!:ª%@'*º$ °~./\\</a></p>`);
|
expect(serialized).toBe(`<p><a href="li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô-íã_a(n)d_underline!:ª%@'*º$-°~./\\" class="internal new">li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#LI NK-W(i)th-àcèô íã_a(n)d_uNdErlinE!:ª%@'*º$ °~./\\</a></p>`);
|
||||||
});
|
});
|
||||||
})
|
|
||||||
|
|
||||||
describe("External links", () => {
|
|
||||||
test("parses an external link that opens in a new tab", () => {
|
|
||||||
const serialized = micromark("[google](https://www.google.com/)", "ascii", {
|
|
||||||
extensions: [syntax()],
|
|
||||||
htmlExtensions: [html() as any],
|
|
||||||
});
|
});
|
||||||
expect(serialized).toBe(`<p><a href="https://www.google.com/" target="_blank">google</a></p>`);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -246,6 +246,28 @@ describe("remark-wiki-link", () => {
|
|||||||
expect(node.data?.hName).toEqual("img");
|
expect(node.data?.hName).toEqual("img");
|
||||||
expect((node.data?.hProperties as any).src).toEqual("My Image.png");
|
expect((node.data?.hProperties as any).src).toEqual("My Image.png");
|
||||||
expect((node.data?.hProperties as any).alt).toEqual("My Image.png");
|
expect((node.data?.hProperties as any).alt).toEqual("My Image.png");
|
||||||
|
expect((node.data?.hProperties as any).width).toBeUndefined();
|
||||||
|
expect((node.data?.hProperties as any).height).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Can identify the dimensions of the image if exists", () => {
|
||||||
|
const processor = unified().use(markdown).use(wikiLinkPlugin);
|
||||||
|
|
||||||
|
let ast = processor.parse("![[My Image.png|132x612]]");
|
||||||
|
ast = processor.runSync(ast);
|
||||||
|
|
||||||
|
expect(select("wikiLink", ast)).not.toEqual(null);
|
||||||
|
|
||||||
|
visit(ast, "wikiLink", (node: Node) => {
|
||||||
|
expect(node.data?.isEmbed).toEqual(true);
|
||||||
|
expect(node.data?.target).toEqual("My Image.png");
|
||||||
|
expect(node.data?.permalink).toEqual("My Image.png");
|
||||||
|
expect(node.data?.hName).toEqual("img");
|
||||||
|
expect((node.data?.hProperties as any).src).toEqual("My Image.png");
|
||||||
|
expect((node.data?.hProperties as any).alt).toEqual("My Image.png");
|
||||||
|
expect((node.data?.hProperties as any).width).toBe("132");
|
||||||
|
expect((node.data?.hProperties as any).height).toBe("612");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,13 +387,17 @@ describe("remark-wiki-link", () => {
|
|||||||
test("parses a link with special characters and symbols", () => {
|
test("parses a link with special characters and symbols", () => {
|
||||||
const processor = unified().use(markdown).use(wikiLinkPlugin);
|
const processor = unified().use(markdown).use(wikiLinkPlugin);
|
||||||
|
|
||||||
let ast = processor.parse("[[li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô íã_a(n)D_UNDERLINE!:ª%@'*º$ °~./\\]]");
|
let ast = processor.parse(
|
||||||
|
"[[li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô íã_a(n)D_UNDERLINE!:ª%@'*º$ °~./\\]]"
|
||||||
|
);
|
||||||
ast = processor.runSync(ast);
|
ast = processor.runSync(ast);
|
||||||
expect(select("wikiLink", ast)).not.toEqual(null);
|
expect(select("wikiLink", ast)).not.toEqual(null);
|
||||||
|
|
||||||
visit(ast, "wikiLink", (node: Node) => {
|
visit(ast, "wikiLink", (node: Node) => {
|
||||||
expect(node.data?.exists).toEqual(false);
|
expect(node.data?.exists).toEqual(false);
|
||||||
expect(node.data?.permalink).toEqual("li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\");
|
expect(node.data?.permalink).toEqual(
|
||||||
|
"li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\"
|
||||||
|
);
|
||||||
expect(node.data?.alias).toEqual(null);
|
expect(node.data?.alias).toEqual(null);
|
||||||
expect(node.data?.hName).toEqual("a");
|
expect(node.data?.hName).toEqual("a");
|
||||||
expect((node.data?.hProperties as any).className).toEqual(
|
expect((node.data?.hProperties as any).className).toEqual(
|
||||||
@@ -383,9 +409,9 @@ describe("remark-wiki-link", () => {
|
|||||||
expect((node.data?.hChildren as any)[0].value).toEqual(
|
expect((node.data?.hChildren as any)[0].value).toEqual(
|
||||||
"li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô íã_a(n)D_UNDERLINE!:ª%@'*º$ °~./\\"
|
"li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\#li-nk-w(i)th-àcèô íã_a(n)D_UNDERLINE!:ª%@'*º$ °~./\\"
|
||||||
);
|
);
|
||||||
})
|
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("invalid wiki links", () => {
|
describe("invalid wiki links", () => {
|
||||||
test("doesn't parse a wiki link with two missing closing brackets", () => {
|
test("doesn't parse a wiki link with two missing closing brackets", () => {
|
||||||
@@ -586,4 +612,3 @@ describe("remark-wiki-link", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user