Compare commits

..

1 Commits

Author SHA1 Message Date
mohamedsalem401
806bc89e8c Open external links in a new tab 2024-02-29 13:52:36 +02:00
41 changed files with 628 additions and 4419 deletions

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"dbaeumer.vscode-eslint"
]
}

View File

@@ -1,56 +1,31 @@
<h1 align="center"> <h1 align="center">
<a href="https://datahub.io/"> 🌀 Portal.JS
<img alt="datahub" src="http://datahub.io/datahub-cube.svg" width="146"> <br />
</a> Rapidly build rich data portals using a modern frontend framework
</h1> </h1>
<p align="center"> * [What is Portal.JS ?](#What-is-Portal.JS)
Bugs, issues and suggestions re DataHub Cloud ☁️ and DataHub OpenSource 🌀 * [Features](#Features)
<br /> * [For developers](#For-developers)
<br /><a href="https://discord.gg/xfFDMPU9dC"><img src="https://dcbadge.vercel.app/api/server/xfFDMPU9dC" /></a> * [Docs](#Docs)
</p> * [Community](#Community)
* [Appendix](#Appendix)
* [What happened to Recline?](#What-happened-to-Recline?)
## DataHub # What is Portal.JS
This repo and issue tracker are for 🌀 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.
- DataHub Cloud ☁️ - https://datahub.io/ 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 🌀 - https://datahub.io/opensource
### Issues ## Features
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. - 📝 Well documented: full set of documentation plus the documentation of Next.js and Apollo.
### For developers ### For developers
@@ -58,3 +33,25 @@ DataHub is built in JavaScript and React on top of the popular [Next.js](https:/
- 🚀 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,5 @@
# @portaljs/components # @portaljs/components
## 1.0.0
### Major Changes
- [#1103](https://github.com/datopian/datahub/pull/1103) [`48cd812a`](https://github.com/datopian/datahub/commit/48cd812a488a069a419d8ecc67f24f94d4d1d1d6) Thanks [@demenech](https://github.com/demenech)! - Components API tidying up and storybook docs improvements.
## 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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@portaljs/components", "name": "@portaljs/components",
"version": "1.0.0", "version": "0.5.10",
"type": "module", "type": "module",
"description": "https://portaljs.org", "description": "https://portaljs.org",
"keywords": [ "keywords": [
@@ -40,13 +40,11 @@
"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",

View File

@@ -7,12 +7,7 @@ export function Catalog({
datasets, datasets,
facets, facets,
}: { }: {
datasets: { datasets: any[];
_id: string | number;
metadata: { title: string; [k: string]: string | number };
url_path: string;
[k: string]: any;
}[];
facets: string[]; facets: string[];
}) { }) {
const [indexFilter, setIndexFilter] = useState(''); const [indexFilter, setIndexFilter] = useState('');
@@ -61,7 +56,7 @@ export function Catalog({
//Then check if the selectedValue for the given facet is included in the dataset metadata //Then check if the selectedValue for the given facet is included in the dataset metadata
.filter((dataset) => { .filter((dataset) => {
//Avoids a server rendering breakage //Avoids a server rendering breakage
if (!watch() || Object.keys(watch()).length === 0) return true; if (!watch() || Object.keys(watch()).length === 0) return true
//This will filter only the key pairs of the metadata values that were selected as facets //This will filter only the key pairs of the metadata values that were selected as facets
const datasetFacets = Object.entries(dataset.metadata).filter((entry) => const datasetFacets = Object.entries(dataset.metadata).filter((entry) =>
facets.includes(entry[0]) facets.includes(entry[0])
@@ -91,7 +86,9 @@ export function Catalog({
className="p-2 ml-1 text-sm shadow border border-block" className="p-2 ml-1 text-sm shadow border border-block"
{...register(elem[0] + '.selectedValue')} {...register(elem[0] + '.selectedValue')}
> >
<option value="">Filter by {elem[0]}</option> <option value="">
Filter by {elem[0]}
</option>
{(elem[1] as { possibleValues: string[] }).possibleValues.map( {(elem[1] as { possibleValues: string[] }).possibleValues.map(
(val) => ( (val) => (
<option <option
@@ -105,10 +102,10 @@ export function Catalog({
)} )}
</select> </select>
))} ))}
<ul className="mb-5 pl-6 mt-5 list-disc"> <ul className='mb-5 pl-6 mt-5 list-disc'>
{filteredDatasets.map((dataset) => ( {filteredDatasets.map((dataset) => (
<li className="py-2" key={dataset._id}> <li className='py-2' key={dataset._id}>
<a className="font-medium underline" href={dataset.url_path}> <a className='font-medium underline' href={dataset.url_path}>
{dataset.metadata.title {dataset.metadata.title
? dataset.metadata.title ? dataset.metadata.title
: dataset.url_path} : dataset.url_path}
@@ -119,3 +116,4 @@ export function Catalog({
</> </>
); );
} }

View File

@@ -4,14 +4,12 @@ import { read, utils } from 'xlsx';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import 'ag-grid-community/styles/ag-grid.css'; import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css'; import 'ag-grid-community/styles/ag-theme-alpine.css';
import { Data } from '../types/properties';
export type ExcelProps = { export type ExcelProps = {
data: Required<Pick<Data, 'url'>>; url: string;
}; };
export function Excel({ data }: ExcelProps) { export function Excel({ url }: ExcelProps) {
const url = data.url;
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [activeSheetName, setActiveSheetName] = useState<string>(); const [activeSheetName, setActiveSheetName] = useState<string>();
const [workbook, setWorkbook] = useState<any>(); const [workbook, setWorkbook] = useState<any>();

View File

@@ -2,7 +2,6 @@ import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import Papa from 'papaparse'; import Papa from 'papaparse';
import { Grid } from '@githubocto/flat-ui'; import { Grid } from '@githubocto/flat-ui';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
import { Data } from '../types/properties';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -37,25 +36,30 @@ export async function parseCsv(file: string, parsingConfig): Promise<any> {
} }
export interface FlatUiTableProps { export interface FlatUiTableProps {
data: Data; url?: string;
uniqueId?: number; data?: { [key: string]: number | string }[];
rawCsv?: string;
randomId?: number;
bytes: number; bytes: number;
parsingConfig: any; parsingConfig: any;
} }
export const FlatUiTable: React.FC<FlatUiTableProps> = ({ export const FlatUiTable: React.FC<FlatUiTableProps> = ({
url,
data, data,
uniqueId, rawCsv,
bytes = 5132288, bytes = 5132288,
parsingConfig = {}, parsingConfig = {},
}) => { }) => {
uniqueId = uniqueId ?? Math.random(); const randomId = Math.random();
return ( return (
// Provide the client to your App // Provide the client to your App
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TableInner <TableInner
bytes={bytes} bytes={bytes}
url={url}
data={data} data={data}
uniqueId={uniqueId} rawCsv={rawCsv}
randomId={randomId}
parsingConfig={parsingConfig} parsingConfig={parsingConfig}
/> />
</QueryClientProvider> </QueryClientProvider>
@@ -63,32 +67,33 @@ export const FlatUiTable: React.FC<FlatUiTableProps> = ({
}; };
const TableInner: React.FC<FlatUiTableProps> = ({ const TableInner: React.FC<FlatUiTableProps> = ({
url,
data, data,
uniqueId, rawCsv,
randomId,
bytes, bytes,
parsingConfig, parsingConfig,
}) => { }) => {
const url = data.url; if (data) {
const csv = data.csv;
const values = data.values;
if (values) {
return ( return (
<div className="w-full" style={{ height: '500px' }}> <div className="w-full" style={{ height: '500px' }}>
<Grid data={values} /> <Grid data={data} />
</div> </div>
); );
} }
const { data: csvString, isLoading: isDownloadingCSV } = useQuery( const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
['dataCsv', url, uniqueId], ['dataCsv', url, randomId],
() => getCsv(url as string, bytes), () => getCsv(url as string, bytes),
{ enabled: !!url } { enabled: !!url }
); );
const { data: parsedData, isLoading: isParsing } = useQuery( const { data: parsedData, isLoading: isParsing } = useQuery(
['dataPreview', csvString, uniqueId], ['dataPreview', csvString, randomId],
() => () =>
parseCsv(csv ? (csv as string) : (csvString as string), parsingConfig), parseCsv(
{ enabled: csv ? true : !!csvString } rawCsv ? (rawCsv as string) : (csvString as string),
parsingConfig
),
{ enabled: rawCsv ? true : !!csvString }
); );
if (isParsing || isDownloadingCSV) if (isParsing || isDownloadingCSV)
<div className="w-full flex justify-center items-center h-[500px]"> <div className="w-full flex justify-center items-center h-[500px]">

View File

@@ -1,17 +1,14 @@
import { CSSProperties } from 'react'; import { CSSProperties } from "react";
import { Data } from '../types/properties';
export interface IframeProps { export interface IframeProps {
data: Required<Pick<Data, 'url'>>; url: string;
style?: CSSProperties; style?: CSSProperties;
} }
export function Iframe({ data, style }: IframeProps) { export function Iframe({
const url = data.url; url, style
}: IframeProps) {
return ( return (
<iframe <iframe src={url} style={style ?? { width: `100%`, height: `100%` }}></iframe>
src={url}
style={style ?? { width: `100%`, height: `100%` }}
></iframe>
); );
} }

View File

@@ -2,33 +2,31 @@ import { useEffect, useState } from 'react';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
import { VegaLite } from './VegaLite'; import { VegaLite } from './VegaLite';
import loadData from '../lib/loadData'; import loadData from '../lib/loadData';
import { Data } from '../types/properties';
type AxisType = 'quantitative' | 'temporal'; type AxisType = 'quantitative' | 'temporal';
type TimeUnit = 'year' | undefined; // or ... type TimeUnit = 'year' | undefined; // or ...
export type LineChartProps = { export type LineChartProps = {
data: Omit<Data, 'csv'>; data: Array<Array<string | number>> | string | { x: string; y: number }[];
title?: string; title?: string;
xAxis: string; xAxis?: string;
xAxisType?: AxisType; xAxisType?: AxisType;
xAxisTimeUnit?: TimeUnit; xAxisTimeUnit: TimeUnit;
yAxis: string; yAxis?: string;
yAxisType?: AxisType; yAxisType?: AxisType;
fullWidth?: boolean; fullWidth?: boolean;
}; };
export function LineChart({ export function LineChart({
data, data = [],
fullWidth = false,
title = '', title = '',
xAxis, xAxis = 'x',
xAxisType = 'temporal', xAxisType = 'temporal',
xAxisTimeUnit = 'year', // TODO: defaults to undefined would probably work better... keeping it as it's for compatibility purposes xAxisTimeUnit = 'year', // TODO: defaults to undefined would probably work better... keeping it as it's for compatibility purposes
yAxis, yAxis = 'y',
yAxisType = 'quantitative', yAxisType = 'quantitative',
}: LineChartProps) { }: LineChartProps) {
const url = data.url;
const values = data.values;
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
// By default, assumes data is an Array... // By default, assumes data is an Array...
@@ -66,12 +64,13 @@ export function LineChart({
} as any; } as any;
useEffect(() => { useEffect(() => {
if (url) { // If data is string, assume it's a URL
if (typeof data === 'string') {
setIsLoading(true); setIsLoading(true);
// Manualy loading the data allows us to do other kinds // Manualy loading the data allows us to do other kinds
// of stuff later e.g. load a file partially // of stuff later e.g. load a file partially
loadData(url).then((res: any) => { loadData(data).then((res: any) => {
setSpecData({ values: res, format: { type: 'csv' } }); setSpecData({ values: res, format: { type: 'csv' } });
setIsLoading(false); setIsLoading(false);
}); });
@@ -79,8 +78,12 @@ export function LineChart({
}, []); }, []);
var vegaData = {}; var vegaData = {};
if (values) { if (Array.isArray(data)) {
vegaData = { table: values }; var dataObj;
dataObj = data.map((r) => {
return { x: r[0], y: r[1] };
});
vegaData = { table: dataObj };
} }
return isLoading ? ( return isLoading ? (
@@ -88,6 +91,6 @@ export function LineChart({
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
) : ( ) : (
<VegaLite data={vegaData} spec={spec} /> <VegaLite fullWidth={fullWidth} data={vegaData} spec={spec} />
); );
} }

View File

@@ -2,7 +2,6 @@ import { CSSProperties, useEffect, useState } from 'react';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
import loadData from '../lib/loadData'; import loadData from '../lib/loadData';
import chroma from 'chroma-js'; import chroma from 'chroma-js';
import { GeospatialData } from '../types/properties';
import { import {
MapContainer, MapContainer,
TileLayer, TileLayer,
@@ -15,25 +14,26 @@ import * as L from 'leaflet';
export type MapProps = { export type MapProps = {
layers: { layers: {
data: GeospatialData; data: string | GeoJSON.GeoJSON;
name: string; name: string;
colorScale?: { colorScale?: {
starting: string; starting: string;
ending: string; ending: string;
}; };
tooltip?: tooltip?:
| { | {
propNames: string[]; propNames: string[];
} }
| boolean; | boolean;
_id?: number;
}[]; }[];
title?: string; title?: string;
center?: { latitude: number | undefined; longitude: number | undefined }; center?: { latitude: number | undefined; longitude: number | undefined };
zoom?: number; zoom?: number;
style?: CSSProperties; style?: CSSProperties;
autoZoomConfiguration?: { autoZoomConfiguration?: {
layerName: string; layerName: string
}; }
}; };
export function Map({ export function Map({
@@ -56,19 +56,17 @@ export function Map({
useEffect(() => { useEffect(() => {
const loadDataPromises = layers.map(async (layer) => { const loadDataPromises = layers.map(async (layer) => {
const url = layer.data.url;
const geojson = layer.data.geojson;
let layerData: any; let layerData: any;
if (url) { if (typeof layer.data === 'string') {
// If "data" is string, assume it's a URL // If "data" is string, assume it's a URL
setIsLoading(true); setIsLoading(true);
layerData = await loadData(url).then((res: any) => { layerData = await loadData(layer.data).then((res: any) => {
return JSON.parse(res); return JSON.parse(res);
}); });
} else { } else {
// Else, expect raw GeoJSON // Else, expect raw GeoJSON
layerData = geojson; layerData = layer.data;
} }
if (layer.colorScale) { if (layer.colorScale) {
@@ -113,23 +111,23 @@ export function Map({
// Create the title box // Create the title box
var info = new L.Control() as any; var info = new L.Control() as any;
info.onAdd = function () { info.onAdd = function() {
this._div = L.DomUtil.create('div', 'info'); this._div = L.DomUtil.create('div', 'info');
this.update(); this.update();
return this._div; return this._div;
}; };
info.update = function () { info.update = function() {
this._div.innerHTML = `<h4 style="font-weight: 600; background: #f9f9f9; padding: 5px; border-radius: 5px; color: #464646;">${title}</h4>`; this._div.innerHTML = `<h4 style="font-weight: 600; background: #f9f9f9; padding: 5px; border-radius: 5px; color: #464646;">${title}</h4>`;
}; };
if (title) info.addTo(map.target); if (title) info.addTo(map.target);
if (!autoZoomConfiguration) return; if(!autoZoomConfiguration) return;
let layerToZoomBounds = L.latLngBounds(L.latLng(0, 0), L.latLng(0, 0)); let layerToZoomBounds = L.latLngBounds(L.latLng(0, 0), L.latLng(0, 0));
layers.forEach((layer) => { layers.forEach((layer) => {
if (layer.name === autoZoomConfiguration.layerName) { if(layer.name === autoZoomConfiguration.layerName) {
const data = layersData.find( const data = layersData.find(
(layerData) => layerData.name === layer.name (layerData) => layerData.name === layer.name
)?.data; )?.data;

View File

@@ -1,24 +1,22 @@
// Core viewer // Core viewer
import { Viewer, Worker, SpecialZoomLevel } from '@react-pdf-viewer/core'; import { Viewer, Worker, SpecialZoomLevel } from '@react-pdf-viewer/core';
import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout'; import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout';
import { Data } from '../types/properties';
// Import styles // Import styles
import '@react-pdf-viewer/core/lib/styles/index.css'; import '@react-pdf-viewer/core/lib/styles/index.css';
import '@react-pdf-viewer/default-layout/lib/styles/index.css'; import '@react-pdf-viewer/default-layout/lib/styles/index.css';
export interface PdfViewerProps { export interface PdfViewerProps {
data: Required<Pick<Data, 'url'>>; url: string;
layout: boolean; layout: boolean;
parentClassName?: string; parentClassName?: string;
} }
export function PdfViewer({ export function PdfViewer({
data, url,
layout = false, layout = false,
parentClassName = 'h-screen', parentClassName,
}: PdfViewerProps) { }: PdfViewerProps) {
const url = data.url;
const defaultLayoutPluginInstance = defaultLayoutPlugin(); const defaultLayoutPluginInstance = defaultLayoutPlugin();
return ( return (
<Worker workerUrl="https://unpkg.com/pdfjs-dist@2.15.349/build/pdf.worker.js"> <Worker workerUrl="https://unpkg.com/pdfjs-dist@2.15.349/build/pdf.worker.js">

View File

@@ -1,9 +0,0 @@
import Plot, { PlotParams } from "react-plotly.js";
export const Plotly: React.FC<PlotParams> = (props) => {
return (
<div>
<Plot {...props} />
</div>
);
};

View File

@@ -1,153 +0,0 @@
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { Plotly } from './Plotly';
import Papa, { ParseConfig } from 'papaparse';
import LoadingSpinner from './LoadingSpinner';
import { Data } from '../types/properties';
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 {
data: Data;
uniqueId?: number;
bytes?: number;
parsingConfig?: ParseConfig;
xAxis: string;
yAxis: string;
// TODO: commented out because this doesn't work. I believe
// this would only make any difference on charts with multiple
// traces.
// lineLabel?: string;
title?: string;
}
export const PlotlyBarChart: React.FC<PlotlyBarChartProps> = ({
data,
bytes = 5132288,
parsingConfig = {},
xAxis,
yAxis,
// lineLabel,
title = '',
}) => {
const uniqueId = Math.random();
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<PlotlyBarChartInner
data={data}
uniqueId={uniqueId}
bytes={bytes}
parsingConfig={parsingConfig}
xAxis={xAxis}
yAxis={yAxis}
// lineLabel={lineLabel ?? yAxis}
title={title}
/>
</QueryClientProvider>
);
};
const PlotlyBarChartInner: React.FC<PlotlyBarChartProps> = ({
data,
uniqueId,
bytes,
parsingConfig,
xAxis,
yAxis,
// lineLabel,
title,
}) => {
if (data.values) {
return (
<div className="w-full" style={{ height: '500px' }}>
<Plotly
layout={{
title,
}}
data={[
{
x: data.values.map((d) => d[xAxis]),
y: data.values.map((d) => d[yAxis]),
type: 'bar',
// name: lineLabel,
},
]}
/>
</div>
);
}
const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
['dataCsv', data.url, uniqueId],
() => getCsv(data.url as string, bytes ?? 5132288),
{ enabled: !!data.url }
);
const { data: parsedData, isLoading: isParsing } = useQuery(
['dataPreview', csvString, uniqueId],
() =>
parseCsv(
data.csv ? (data.csv as string) : (csvString as string),
parsingConfig ?? {}
),
{ enabled: data.csv ? 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, TODO: commented out because this doesn't work
},
]}
/>
</div>
);
return (
<div className="w-full flex justify-center items-center h-[500px]">
<LoadingSpinner />
</div>
);
};

View File

@@ -1,155 +0,0 @@
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { Plotly } from './Plotly';
import Papa, { ParseConfig } from 'papaparse';
import LoadingSpinner from './LoadingSpinner';
import { Data } from '../types/properties';
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 {
data: Data;
bytes?: number;
parsingConfig?: ParseConfig;
xAxis: string;
yAxis: string;
lineLabel?: string;
title?: string;
uniqueId?: number;
}
export const PlotlyLineChart: React.FC<PlotlyLineChartProps> = ({
data,
bytes = 5132288,
parsingConfig = {},
xAxis,
yAxis,
lineLabel,
title = '',
uniqueId,
}) => {
uniqueId = uniqueId ?? Math.random();
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<LineChartInner
data={data}
uniqueId={uniqueId}
bytes={bytes}
parsingConfig={parsingConfig}
xAxis={xAxis}
yAxis={yAxis}
lineLabel={lineLabel ?? yAxis}
title={title}
/>
</QueryClientProvider>
);
};
const LineChartInner: React.FC<PlotlyLineChartProps> = ({
data,
uniqueId,
bytes,
parsingConfig,
xAxis,
yAxis,
lineLabel,
title,
}) => {
const values = data.values;
const url = data.url;
const csv = data.csv;
if (values) {
return (
<div className="w-full" style={{ height: '500px' }}>
<Plotly
layout={{
title,
}}
data={[
{
x: values.map((d) => d[xAxis]),
y: values.map((d) => d[yAxis]),
mode: 'lines',
name: lineLabel,
},
]}
/>
</div>
);
}
const { data: csvString, isLoading: isDownloadingCSV } = useQuery(
['dataCsv', url, uniqueId],
() => getCsv(url as string, bytes ?? 5132288),
{ enabled: !!url }
);
const { data: parsedData, isLoading: isParsing } = useQuery(
['dataPreview', csvString, uniqueId],
() =>
parseCsv(
csv ? (csv as string) : (csvString as string),
parsingConfig ?? {}
),
{ enabled: csv ? 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>
);
};

View File

@@ -1,7 +1,6 @@
// Wrapper for the Vega component // Wrapper for the Vega component
import { Vega as VegaOg } from "react-vega"; import { Vega as VegaOg } from "react-vega";
import { VegaProps } from "react-vega/lib/Vega";
export function Vega(props: VegaProps) { export function Vega(props) {
return <VegaOg {...props} />; return <VegaOg {...props} />;
} }

View File

@@ -1,9 +1,8 @@
// Wrapper for the Vega Lite component // Wrapper for the Vega Lite component
import { VegaLite as VegaLiteOg } from 'react-vega'; import { VegaLite as VegaLiteOg } from "react-vega";
import { VegaLiteProps } from 'react-vega/lib/VegaLite'; import applyFullWidthDirective from "../lib/applyFullWidthDirective";
import applyFullWidthDirective from '../lib/applyFullWidthDirective';
export function VegaLite(props: VegaLiteProps) { export function VegaLite(props) {
const Component = applyFullWidthDirective({ Component: VegaLiteOg }); const Component = applyFullWidthDirective({ Component: VegaLiteOg });
return <Component {...props} />; return <Component {...props} />;

View File

@@ -1,17 +1,12 @@
export * from './components/Table';
export * from './components/Catalog'; export * from './components/Catalog';
export * from './components/LineChart'; export * from './components/LineChart';
export * from './components/Vega'; export * from './components/Vega';
export * from './components/VegaLite'; export * from './components/VegaLite';
export * from './components/FlatUiTable'; export * from './components/FlatUiTable';
export * from './components/OpenLayers/OpenLayers';
export * from './components/Map'; export * from './components/Map';
export * from './components/PdfViewer'; export * from './components/PdfViewer';
export * from "./components/Excel"; export * from "./components/Excel";
export * from "./components/BucketViewer";
export * from "./components/Iframe"; export * from "./components/Iframe";
export * from "./components/Plotly";
export * from "./components/PlotlyLineChart";
export * from "./components/PlotlyBarChart";
// NOTE: components that are hidden for now
// TODO: deprecate those components?
// export * from './components/Table';
// export * from "./components/BucketViewer";
// export * from './components/OpenLayers/OpenLayers';

View File

@@ -1,18 +0,0 @@
/*
* All components should use this interface for
* its data property.
* Based on vega.
*
*/
type URL = string; // Just in case we want to transform it into an object with configurations
export interface Data {
url?: URL;
values?: { [key: string]: number | string }[];
csv?: string;
}
export interface GeospatialData {
url?: URL;
geojson?: GeoJSON.GeoJSON;
}

View File

@@ -1,6 +1,3 @@
// NOTE: this component was renamed with .bkp so that it's hidden
// from the Storybook app
import { type Meta, type StoryObj } from '@storybook/react'; import { type Meta, type StoryObj } from '@storybook/react';
import { import {

View File

@@ -10,14 +10,11 @@ const meta: Meta = {
argTypes: { argTypes: {
datasets: { datasets: {
description: description:
"Array of items to be displayed on the searchable list. Must have the following properties: \n\n \ 'Lists of datasets to be displayed in the list, will usually be automatically available',
`_id`: item's unique id \n\n \
`url_path`: href of the item \n\n \
`metadata`: object with a `title` property, that will be displayed as the title of the item, together with any other custom fields that might or not be faceted.",
}, },
facets: { facets: {
description: description:
"Array of strings, which are name of properties in the datasets' `metadata`, which are going to be faceted.", 'List of frontmatter fields that should be used as filters, needs to match exactly with the field name',
}, },
}, },
}; };
@@ -34,42 +31,7 @@ export const WithoutFacets: Story = {
{ {
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae', _id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
url_path: 'dataset-4', url_path: 'dataset-4',
metadata: { file_path: 'content/dataset-4/index.md',
title: 'Detecting Abusive Albanian',
},
},
{
_id: '42c86cf3c4fbbab11d91c2a7d6dcb8f750bc4e19',
url_path: 'dataset-1',
metadata: {
title: 'AbuseEval v1.0',
},
},
{
_id: '80001dd32a752421fdcc64e91fbd237dc31d6bb3',
url_path: 'dataset-2',
metadata: {
title:
'Abusive Language Detection on Arabic Social Media (Al Jazeera)',
},
},
{
_id: '96649d05d8193f4333b10015af76c6562971bd8c',
url_path: 'dataset-3',
metadata: {
title: 'CoRAL: a Context-aware Croatian Abusive Language Dataset',
},
},
],
},
};
export const WithFacets: Story = {
name: 'Catalog with facets',
args: {
datasets: [
{
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
url_path: 'dataset-4',
metadata: { metadata: {
title: 'Detecting Abusive Albanian', title: 'Detecting Abusive Albanian',
'link-to-publication': 'https://arxiv.org/abs/2107.13592', 'link-to-publication': 'https://arxiv.org/abs/2107.13592',
@@ -158,6 +120,107 @@ export const WithFacets: Story = {
}, },
}, },
], ],
facets: ['language', 'platform'],
}, },
}; };
;
export const WithFacets: Story = {
name: 'Catalog with facets',
args: {
datasets: [
{
_id: '07026b22d49916754df1dc8ffb9ccd1c31878aae',
url_path: 'dataset-4',
file_path: 'content/dataset-4/index.md',
metadata: {
title: 'Detecting Abusive Albanian',
'link-to-publication': 'https://arxiv.org/abs/2107.13592',
'link-to-data': 'https://doi.org/10.6084/m9.figshare.19333298.v1',
'task-description':
'Hierarchical (offensive/not; untargeted/targeted; person/group/other)',
'details-of-task':
'Detect and categorise abusive language in social media data',
'size-of-dataset': 11874,
'percentage-abusive': 13.2,
language: 'Albanian',
'level-of-annotation': ['Posts'],
platform: ['Instagram', 'Youtube'],
medium: ['Text'],
reference:
'Nurce, E., Keci, J., Derczynski, L., 2021. Detecting Abusive Albanian. arXiv:2107.13592',
},
},
{
_id: '42c86cf3c4fbbab11d91c2a7d6dcb8f750bc4e19',
url_path: 'dataset-1',
file_path: 'content/dataset-1/index.md',
metadata: {
title: 'AbuseEval v1.0',
'link-to-publication':
'http://www.lrec-conf.org/proceedings/lrec2020/pdf/2020.lrec-1.760.pdf',
'link-to-data': 'https://github.com/tommasoc80/AbuseEval',
'task-description':
'Explicitness annotation of offensive and abusive content',
'details-of-task':
'Enriched versions of the OffensEval/OLID dataset with the distinction of explicit/implicit offensive messages and the new dimension for abusive messages. Labels for offensive language: EXPLICIT, IMPLICT, NOT; Labels for abusive language: EXPLICIT, IMPLICT, NOTABU',
'size-of-dataset': 14100,
'percentage-abusive': 20.75,
language: 'English',
'level-of-annotation': ['Tweets'],
platform: ['Twitter'],
medium: ['Text'],
reference:
'Caselli, T., Basile, V., Jelena, M., Inga, K., and Michael, G. 2020. "I feel offended, dont be abusive! implicit/explicit messages in offensive and abusive language". The 12th Language Resources and Evaluation Conference (pp. 6193-6202). European Language Resources Association.',
},
},
{
_id: '80001dd32a752421fdcc64e91fbd237dc31d6bb3',
url_path: 'dataset-2',
file_path: 'content/dataset-2/index.md',
metadata: {
title:
'Abusive Language Detection on Arabic Social Media (Al Jazeera)',
'link-to-publication': 'https://www.aclweb.org/anthology/W17-3008',
'link-to-data':
'http://alt.qcri.org/~hmubarak/offensive/AJCommentsClassification-CF.xlsx',
'task-description':
'Ternary (Obscene, Offensive but not obscene, Clean)',
'details-of-task': 'Incivility',
'size-of-dataset': 32000,
'percentage-abusive': 0.81,
language: 'Arabic',
'level-of-annotation': ['Posts'],
platform: ['AlJazeera'],
medium: ['Text'],
reference:
'Mubarak, H., Darwish, K. and Magdy, W., 2017. Abusive Language Detection on Arabic Social Media. In: Proceedings of the First Workshop on Abusive Language Online. Vancouver, Canada: Association for Computational Linguistics, pp.52-56.',
},
},
{
_id: '96649d05d8193f4333b10015af76c6562971bd8c',
url_path: 'dataset-3',
file_path: 'content/dataset-3/index.md',
metadata: {
title: 'CoRAL: a Context-aware Croatian Abusive Language Dataset',
'link-to-publication':
'https://aclanthology.org/2022.findings-aacl.21/',
'link-to-data':
'https://github.com/shekharRavi/CoRAL-dataset-Findings-of-the-ACL-AACL-IJCNLP-2022',
'task-description':
'Multi-class based on context dependency categories (CDC)',
'details-of-task': 'Detectioning CDC from abusive comments',
'size-of-dataset': 2240,
'percentage-abusive': 100,
language: 'Croatian',
'level-of-annotation': ['Posts'],
platform: ['Posts'],
medium: ['Newspaper Comments'],
reference:
'Ravi Shekhar, Mladen Karan and Matthew Purver (2022). CoRAL: a Context-aware Croatian Abusive Language Dataset. Findings of the ACL: AACL-IJCNLP.',
},
},
],
facets: ['language', 'platform']
},
};
;

View File

@@ -4,13 +4,13 @@ import { Excel, ExcelProps } from '../src/components/Excel';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Tabular/Excel', title: 'Components/Excel',
component: Excel, component: Excel,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
data: { url: {
description: description:
'Object with a `url` property pointing to the Excel file to be displayed, e.g.: `{ url: "https://url.to/data.csv" }`', 'Url of the file to be displayed e.g.: "https://url.to/data.csv"',
}, },
}, },
}; };
@@ -22,17 +22,13 @@ type Story = StoryObj<ExcelProps>;
export const SingleSheet: Story = { export const SingleSheet: Story = {
name: 'Excel file with just one sheet', name: 'Excel file with just one sheet',
args: { args: {
data: { url: 'https://sheetjs.com/pres.xlsx',
url: 'https://sheetjs.com/pres.xlsx',
},
}, },
}; };
export const MultipleSheet: Story = { export const MultipleSheet: Story = {
name: 'Excel file with multiple sheets', name: 'Excel file with multiple sheets',
args: { args: {
data: { url: 'https://storage.portaljs.org/IC-Gantt-Chart-Project-Template-8857.xlsx',
url: 'https://storage.portaljs.org/IC-Gantt-Chart-Project-Template-8857.xlsx',
},
}, },
}; };

View File

@@ -4,31 +4,29 @@ import { FlatUiTable, FlatUiTableProps } from '../src/components/FlatUiTable';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Tabular/FlatUiTable', title: 'Components/FlatUiTable',
component: FlatUiTable, component: FlatUiTable,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
data: { data: {
description: description:
'Data to be displayed. \n\n \ 'Data to be displayed in the table, must be setup as an array of key value pairs',
Must be an object with one of the following properties: `url`, `values` or `csv` \n\n \ },
`url`: URL pointing to a CSV file. \n\n \ csv: {
`values`: array of objects. \n\n \ description: 'CSV data as string.',
`csv`: string with valid CSV. \n\n \ },
', url: {
description:
'Fetch the data from a CSV file remotely. only the first 5MB of data will be displayed',
}, },
bytes: { bytes: {
description: description:
'Fetch the data from a CSV file remotely. Only the first <bytes> of data will be displayed. Defaults to 5MB.', 'Fetch the data from a CSV file remotely. only the first <bytes> of data will be displayed',
}, },
parsingConfig: { parsingConfig: {
description: description:
'Configuration for parsing the CSV data. See https://www.papaparse.com/docs#config for more details', 'Configuration for parsing the CSV data. See https://www.papaparse.com/docs#config for more details',
}, },
uniqueId: {
description:
'Provide a unique ID to help with cache revalidation of the fetched data.',
},
}, },
}; };
@@ -38,40 +36,34 @@ type Story = StoryObj<FlatUiTableProps>;
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const FromColumnsAndData: Story = { export const FromColumnsAndData: Story = {
name: 'Table from array or objects', name: 'Table data',
args: { args: {
data: { data: [
values: [ { id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 },
{ id: 1, lastName: 'Snow', firstName: 'Jon', age: 35 }, { id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 },
{ id: 2, lastName: 'Lannister', firstName: 'Cersei', age: 42 }, { id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 },
{ id: 3, lastName: 'Lannister', firstName: 'Jaime', age: 45 }, { id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 },
{ id: 4, lastName: 'Stark', firstName: 'Arya', age: 16 }, { id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 },
{ id: 7, lastName: 'Clifford', firstName: 'Ferrara', age: 44 }, { id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 },
{ id: 8, lastName: 'Frances', firstName: 'Rossini', age: 36 }, { id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 },
{ id: 9, lastName: 'Roxie', firstName: 'Harvey', age: 65 }, ],
],
},
}, },
}; };
export const FromRawCSV: Story = { export const FromRawCSV: Story = {
name: 'Table from inline CSV', name: 'Table from raw CSV',
args: { args: {
data: { rawCsv: `
csv: `
Year,Temp Anomaly Year,Temp Anomaly
1850,-0.418 1850,-0.418
2020,0.923 2020,0.923
`, `,
},
}, },
}; };
export const FromURL: Story = { export const FromURL: Story = {
name: 'Table from URL', name: 'Table from URL',
args: { args: {
data: { url: 'https://storage.openspending.org/alberta-budget/__os_imported__alberta_total.csv',
url: 'https://storage.openspending.org/alberta-budget/__os_imported__alberta_total.csv',
},
}, },
}; };

View File

@@ -3,17 +3,17 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { Iframe, IframeProps } from '../src/components/Iframe'; import { Iframe, IframeProps } from '../src/components/Iframe';
const meta: Meta = { const meta: Meta = {
title: 'Components/Embedding/Iframe', title: 'Components/Iframe',
component: Iframe, component: Iframe,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
data: { url: {
description: description:
'Object with a `url` property pointing to the page to be embeded.', 'Page to display inside of the component',
}, },
style: { style: {
description: description:
'Style object of the component. See example at https://react.dev/learn#displaying-data. Defaults to `{ width: "100%", height: "100%" }`', 'Style of the component',
}, },
}, },
}; };
@@ -25,9 +25,7 @@ type Story = StoryObj<IframeProps>;
export const Normal: Story = { export const Normal: Story = {
name: 'Iframe', name: 'Iframe',
args: { args: {
data: { url: 'https://app.powerbi.com/view?r=eyJrIjoiYzBmN2Q2MzYtYzE3MS00ODkxLWE5OWMtZTQ2MjBlMDljMDk4IiwidCI6Ijk1M2IwZjgzLTFjZTYtNDVjMy04MmM5LTFkODQ3ZTM3MjMzOSIsImMiOjh9',
url: 'https://app.powerbi.com/view?r=eyJrIjoiYzBmN2Q2MzYtYzE3MS00ODkxLWE5OWMtZTQ2MjBlMDljMDk4IiwidCI6Ijk1M2IwZjgzLTFjZTYtNDVjMy04MmM5LTFkODQ3ZTM3MjMzOSIsImMiOjh9', style: {width: `100%`, height: `100%`}
},
style: { width: `100%`, height: `100%` },
}, },
}; };

View File

@@ -4,36 +4,37 @@ import { LineChart, LineChartProps } from '../src/components/LineChart';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Charts/LineChart', title: 'Components/LineChart',
component: LineChart, component: LineChart,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
data: { data: {
description: description:
'Data to be displayed. \n\n \ 'Data to be displayed.\n\n E.g.: [["1990", 1], ["1991", 2]] \n\nOR\n\n "https://url.to/data.csv"',
Must be an object with one of the following properties: `url` or `values` \n\n \
`url`: URL pointing to a CSV file. \n\n \
`values`: array of objects \n\n',
}, },
title: { title: {
description: 'Title to display on the chart.', description: 'Title to display on the chart. Optional.',
}, },
xAxis: { xAxis: {
description: description:
'Name of the column header or object property that represents the X-axis on the data.', 'Name of the X axis on the data. Required when the "data" parameter is an URL.',
}, },
xAxisType: { xAxisType: {
description: 'Type of the X-axis.', description: 'Type of the X axis',
}, },
xAxisTimeUnit: { xAxisTimeUnit: {
description: 'Time unit of the X-axis, in case its type is `temporal.`', description: 'Time unit of the X axis (optional)',
}, },
yAxis: { yAxis: {
description: description:
'Name of the column header or object property that represents the Y-axis on the data.', 'Name of the Y axis on the data. Required when the "data" parameter is an URL.',
}, },
yAxisType: { yAxisType: {
description: 'Type of the Y-axis', description: 'Type of the Y axis',
},
fullWidth: {
description:
'Whether the component should be rendered as full bleed or not',
}, },
}, },
}; };
@@ -46,27 +47,21 @@ type Story = StoryObj<LineChartProps>;
export const FromDataPoints: Story = { export const FromDataPoints: Story = {
name: 'Line chart from array of data points', name: 'Line chart from array of data points',
args: { args: {
data: { data: [
values: [ ['1850', -0.41765878],
{ year: '1850', value: -0.41765878 }, ['1851', -0.2333498],
{ year: '1851', value: -0.2333498 }, ['1852', -0.22939907],
{ year: '1852', value: -0.22939907 }, ['1853', -0.27035445],
{ year: '1853', value: -0.27035445 }, ['1854', -0.29163003],
{ year: '1854', value: -0.29163003 }, ],
],
},
xAxis: 'year',
yAxis: 'value',
}, },
}; };
export const FromURL: Story = { export const FromURL: Story = {
name: 'Line chart from URL', name: 'Line chart from URL',
args: { args: {
data: {
url: 'https://raw.githubusercontent.com/datasets/oil-prices/main/data/wti-year.csv',
},
title: 'Oil Price x Year', title: 'Oil Price x Year',
data: 'https://raw.githubusercontent.com/datasets/oil-prices/main/data/wti-year.csv',
xAxis: 'Date', xAxis: 'Date',
yAxis: 'Price', yAxis: 'Price',
}, },

View File

@@ -4,34 +4,29 @@ import { Map, MapProps } from '../src/components/Map';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Geospatial/Map', title: 'Components/Map',
component: Map, component: Map,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
layers: { layers: {
description: description:
'Array of layers to be displayed on the map. Should be an object with: \n\n \ 'Data to be displayed.\n\n GeoJSON Object \n\nOR\n\n URL to GeoJSON Object',
`data`: object with either a `url` property pointing to a GeoJSON file or a `geojson` property with a GeoJSON object. \n\n \
`name`: name of the layer. \n\n \
`colorscale`: object with a `starting` and `ending` colors that will be used to create a gradient and color the map. \n\n \
`tooltip`: `true` to show all available features on the tooltip, object with a `propNames` property as an array of strings to choose which features to display. \n\n',
}, },
title: { title: {
description: 'Title to display on the map.', description: 'Title to display on the map. Optional.',
}, },
center: { center: {
description: 'Initial coordinates of the center of the map', description: 'Initial coordinates of the center of the map',
}, },
zoom: { zoom: {
description: 'Initial zoom level', description: 'Zoom level',
}, },
style: { style: {
description: "CSS styles to be applied to the map's container.", description: "Styles for the container"
}, },
autoZoomConfiguration: { autoZoomConfiguration: {
description: description: "Configuration to auto zoom in the specified layer data"
"Pass a layer's name to automatically zoom to the bounding area of a layer.", }
},
}, },
}; };
@@ -45,9 +40,7 @@ export const GeoJSONPolygons: Story = {
args: { args: {
layers: [ layers: [
{ {
data: { data: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
url: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
},
name: 'Polygons', name: 'Polygons',
tooltip: { propNames: ['name'] }, tooltip: { propNames: ['name'] },
colorScale: { colorScale: {
@@ -67,9 +60,7 @@ export const GeoJSONPoints: Story = {
args: { args: {
layers: [ layers: [
{ {
data: { data: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
url: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
},
name: 'Points', name: 'Points',
tooltip: { propNames: ['Location'] }, tooltip: { propNames: ['Location'] },
}, },
@@ -85,16 +76,12 @@ export const GeoJSONMultipleLayers: Story = {
args: { args: {
layers: [ layers: [
{ {
data: { data: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
url: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
},
name: 'Points', name: 'Points',
tooltip: true, tooltip: true,
}, },
{ {
data: { data: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
url: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
},
name: 'Polygons', name: 'Polygons',
tooltip: true, tooltip: true,
colorScale: { colorScale: {
@@ -107,23 +94,19 @@ export const GeoJSONMultipleLayers: Story = {
center: { latitude: 45, longitude: 0 }, center: { latitude: 45, longitude: 0 },
zoom: 2, zoom: 2,
}, },
}; }
export const GeoJSONMultipleLayersWithAutoZoomInSpecifiedLayer: Story = { export const GeoJSONMultipleLayersWithAutoZoomInSpecifiedLayer: Story = {
name: 'GeoJSON polygons and points map with auto zoom in the points layer', name: 'GeoJSON polygons and points map with auto zoom in the points layer',
args: { args: {
layers: [ layers: [
{ {
data: { data: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
url: 'https://opendata.arcgis.com/datasets/9c58741995174fbcb017cf46c8a42f4b_25.geojson',
},
name: 'Points', name: 'Points',
tooltip: true, tooltip: true,
}, },
{ {
data: { data: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
url: 'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_marine_polys.geojson',
},
name: 'Polygons', name: 'Polygons',
tooltip: true, tooltip: true,
colorScale: { colorScale: {
@@ -136,7 +119,7 @@ export const GeoJSONMultipleLayersWithAutoZoomInSpecifiedLayer: Story = {
center: { latitude: 45, longitude: 0 }, center: { latitude: 45, longitude: 0 },
zoom: 2, zoom: 2,
autoZoomConfiguration: { autoZoomConfiguration: {
layerName: 'Points', layerName: 'Points'
}, }
}, },
}; };

View File

@@ -1,6 +1,3 @@
// NOTE: this component was renamed with .bkp so that it's hidden
// from the Storybook app
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import React from 'react'; import React from 'react';
import OpenLayers from '../src/components/OpenLayers/OpenLayers'; import OpenLayers from '../src/components/OpenLayers/OpenLayers';

View File

@@ -3,21 +3,19 @@ import type { Meta, StoryObj } from '@storybook/react';
import { PdfViewer, PdfViewerProps } from '../src/components/PdfViewer'; import { PdfViewer, PdfViewerProps } from '../src/components/PdfViewer';
const meta: Meta = { const meta: Meta = {
title: 'Components/Embedding/PdfViewer', title: 'Components/PdfViewer',
component: PdfViewer, component: PdfViewer,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
data: { url: {
description: description: 'URL to PDF file',
'Object with a `url` property pointing to the PDF file to be displayed, e.g.: `{ url: "https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK" }`.',
}, },
parentClassName: { parentClassName: {
description: description: 'Classname for the parent div of the pdf viewer',
'HTML classes to be applied to the container of the PDF viewer. [Tailwind](https://tailwindcss.com/) classes, such as `h-96` to define the height of the component, can be used on this field.',
}, },
layout: { layour: {
description: description:
'Set to `true` if you want to display a layout with zoom level, page count, printing button and other controls.', 'Set to true if you want to have a layout with zoom level, page count, printing button etc',
defaultValue: false, defaultValue: false,
}, },
}, },
@@ -27,23 +25,26 @@ export default meta;
type Story = StoryObj<PdfViewerProps>; type Story = StoryObj<PdfViewerProps>;
export const PdfViewerStoryWithoutControlsLayout: Story = { export const PdfViewerStory: Story = {
name: 'PDF Viewer without controls layout', name: 'PdfViewer',
args: { args: {
data: { url: 'https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK',
url: 'https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK',
},
parentClassName: 'h-96',
}, },
}; };
export const PdfViewerStoryWithControlsLayout: Story = { export const PdfViewerStoryWithLayout: Story = {
name: 'PdfViewer with controls layout', name: 'PdfViewer with the default layout',
args: { args: {
data: { url: 'https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK',
url: 'https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK', layout: true,
}, },
};
export const PdfViewerStoryWithHeight: Story = {
name: 'PdfViewer with a custom height',
args: {
url: 'https://cdn.filestackcontent.com/wcrjf9qPTCKXV3hMXDwK',
parentClassName: 'h-96',
layout: true, layout: true,
parentClassName: 'h-96',
}, },
}; };

View File

@@ -1,49 +0,0 @@
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/Charts/Plotly',
component: Plotly,
tags: ['autodocs'],
argTypes: {
data: {
description:
"Plotly's `data` prop. You can find references on how to use these props at https://github.com/plotly/react-plotly.js/#basic-props.",
},
layout: {
description:
"Plotly's `layout` prop. You can find references on how to use these props at https://github.com/plotly/react-plotly.js/#basic-props.",
},
},
};
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: 'Line chart',
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',
},
},
},
};

View File

@@ -1,102 +0,0 @@
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/Charts/PlotlyBarChart',
component: PlotlyBarChart,
tags: ['autodocs'],
argTypes: {
data: {
description:
'Data to be displayed. \n\n \
Must be an object with one of the following properties: `url`, `values` or `csv` \n\n \
`url`: URL pointing to a CSV file. \n\n \
`values`: array of objects (check out [this example](/?path=/story/components-plotlybarchart--from-data-points)) \n\n \
`csv`: string with valid CSV (check out [this example](/?path=/story/components-plotlybarchart--from-inline-csv)) \n\n \
',
},
bytes: {
// TODO: likely this should be an extra option on the data parameter,
// specific to URLs
description:
"How many bytes to read from the url so that the entire file doesn's have to be fetched.",
},
parsingConfig: {
description:
'If using URL or CSV, this parsing config will be used to parse the data. Check https://www.papaparse.com/ for more info.',
},
title: {
description: 'Title to display on the chart.',
},
// TODO: commented out because this doesn't work
// lineLabel: {
// description:
// 'Label to display on the line, Optional, will use yAxis if not provided',
// },
xAxis: {
description:
'Name of the column header or object property that represents the X-axis on the data.',
},
yAxis: {
description:
'Name of the column header or object property that represents the Y-axis on the data.',
},
uniqueId: {
description: 'Provide a unique ID to help with cache revalidation of the fetched data.'
}
},
};
export default meta;
type Story = StoryObj<PlotlyBarChartProps>;
export const FromDataPoints: Story = {
name: 'Bar chart from array of data points',
args: {
data: {
values: [
{ 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: 'Bar chart from URL',
args: {
title: 'Apple Stock Prices',
data: {
url: 'https://raw.githubusercontent.com/plotly/datasets/master/finance-charts-apple.csv',
},
xAxis: 'Date',
yAxis: 'AAPL.Open',
},
};
export const FromInlineCSV: Story = {
name: 'Bar chart from inline CSV',
args: {
title: 'Apple Stock Prices',
data: {
csv: `Date,AAPL.Open,AAPL.High,AAPL.Low,AAPL.Close,AAPL.Volume,AAPL.Adjusted,dn,mavg,up,direction
2015-02-17,127.489998,128.880005,126.919998,127.830002,63152400,122.905254,106.7410523,117.9276669,129.1142814,Increasing
2015-02-18,127.629997,128.779999,127.449997,128.720001,44891700,123.760965,107.842423,118.9403335,130.0382439,Increasing
2015-02-19,128.479996,129.029999,128.330002,128.449997,37362400,123.501363,108.8942449,119.8891668,130.8840887,Decreasing
2015-02-20,128.619995,129.5,128.050003,129.5,48948400,124.510914,109.7854494,120.7635001,131.7415509,Increasing`,
},
xAxis: 'Date',
yAxis: 'AAPL.Open',
},
};

View File

@@ -1,101 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
PlotlyLineChart,
PlotlyLineChartProps,
} from '../src/components/PlotlyLineChart';
const meta: Meta = {
title: 'Components/Charts/PlotlyLineChart',
component: PlotlyLineChart,
tags: ['autodocs'],
argTypes: {
data: {
description:
'Data to be displayed. \n\n \
Must be an object with one of the following properties: `url`, `values` or `csv` \n\n \
`url`: URL pointing to a CSV file. \n\n \
`values`: array of objects. \n\n \
`csv`: string with valid CSV. \n\n \
',
},
bytes: {
// TODO: likely this should be an extra option on the data parameter,
// specific to URLs
description:
"How many bytes to read from the url so that the entire file doesn's have to be fetched.",
},
parsingConfig: {
description:
'If using URL or CSV, this parsing config will be used to parse the data. Check https://www.papaparse.com/ for more info',
},
title: {
description: 'Title to display on the chart.',
},
lineLabel: {
description:
'Label to display on the line, will use yAxis if not provided',
},
xAxis: {
description:
'Name of the column header or object property that represents the X-axis on the data.',
},
yAxis: {
description:
'Name of the column header or object property that represents the Y-axis on the data.',
},
uniqueId: {
description:
'Provide a unique ID to help with cache revalidation of the fetched data.',
},
},
};
export default meta;
type Story = StoryObj<PlotlyLineChartProps>;
export const FromDataPoints: Story = {
name: 'Line chart from array of data points',
args: {
data: {
values: [
{ 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',
data: {
url: 'https://raw.githubusercontent.com/datasets/oil-prices/main/data/wti-year.csv',
},
xAxis: 'Date',
yAxis: 'Price',
},
};
export const FromInlineCSV: Story = {
name: 'Bar chart from inline CSV',
args: {
title: 'Apple Stock Prices',
data: {
csv: `Date,AAPL.Open,AAPL.High,AAPL.Low,AAPL.Close,AAPL.Volume,AAPL.Adjusted,dn,mavg,up,direction
2015-02-17,127.489998,128.880005,126.919998,127.830002,63152400,122.905254,106.7410523,117.9276669,129.1142814,Increasing
2015-02-18,127.629997,128.779999,127.449997,128.720001,44891700,123.760965,107.842423,118.9403335,130.0382439,Increasing
2015-02-19,128.479996,129.029999,128.330002,128.449997,37362400,123.501363,108.8942449,119.8891668,130.8840887,Decreasing
2015-02-20,128.619995,129.5,128.050003,129.5,48948400,124.510914,109.7854494,120.7635001,131.7415509,Increasing`,
},
xAxis: 'Date',
yAxis: 'AAPL.Open',
},
};

View File

@@ -1,13 +1,10 @@
// NOTE: this component was renamed with .bkp so that it's hidden
// from the Storybook app
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { Table, TableProps } from '../src/components/Table'; import { Table, TableProps } from '../src/components/Table';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Tabular/Table', title: 'Components/Table',
component: Table, component: Table,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {

View File

@@ -4,19 +4,9 @@ import { Vega } from '../src/components/Vega';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Charts/Vega', title: 'Components/Vega',
component: Vega, component: Vega,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: {
data: {
description:
"Vega's `data` prop. You can find references on how to use this prop at https://vega.github.io/vega/docs/data/",
},
spec: {
description:
"Vega's `spec` prop. You can find references on how to use this prop at https://vega.github.io/vega/docs/specification/",
},
},
}; };
export default meta; export default meta;
@@ -25,7 +15,7 @@ type Story = StoryObj<any>;
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = { export const Primary: Story = {
name: 'Bar chart', name: 'Chart built with Vega',
args: { args: {
data: { data: {
table: [ table: [

View File

@@ -4,7 +4,7 @@ import { VegaLite } from '../src/components/VegaLite';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta: Meta = { const meta: Meta = {
title: 'Components/Charts/VegaLite', title: 'Components/VegaLite',
component: VegaLite, component: VegaLite,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
@@ -25,7 +25,7 @@ type Story = StoryObj<any>;
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = { export const Primary: Story = {
name: 'Bar chart', name: 'Chart built with Vega Lite',
args: { args: {
data: { data: {
table: [ table: [

View File

@@ -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, index) => ( {sortNavGroupChildren(nav).map((n) => (
<NavComponent key={index} item={n} isActive={false} /> <NavComponent 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, index) => ( {sortNavGroupChildren(item.children).map((subItem) => (
<NavComponent key={index} item={subItem} isActive={false} /> <NavComponent item={subItem} isActive={false} />
))} ))}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>

View File

@@ -1,11 +1,5 @@
# @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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@portaljs/remark-wiki-link", "name": "@portaljs/remark-wiki-link",
"version": "1.2.0", "version": "1.1.2",
"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",

View File

@@ -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,23 +25,14 @@ 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;
@@ -53,9 +44,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"?
@@ -89,18 +80,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 {
@@ -115,19 +106,21 @@ 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) {
@@ -135,55 +128,48 @@ 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 {
const hasDimensions = alias && /^\d+(x\d+)?$/.test(alias); wikiLink.data.hName = "img";
// Take the target as alt text except if alt name was provided [[target|alt text]] wikiLink.data.hProperties = {
const altText = hasDimensions || !alias ? target : alias; className: classNames,
src: hrefTemplate(link),
wikiLink.data.hName = 'img'; alt: displayName,
wikiLink.data.hProperties = { };
className: classNames,
src: hrefTemplate(link),
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",
}; };
wikiLink.data.hChildren = [{ type: 'text', value: displayName }]; if(isExternal){
wikiLink.data.hProperties.target = "_blank"; // Open in a new tab
}
wikiLink.data.hChildren = [{ type: "text", value: displayName }];
} }
} }

View File

@@ -1,24 +1,23 @@
import { getImageSize } from './fromMarkdown'; 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 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
@@ -29,11 +28,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;
@@ -42,21 +41,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;
} }
@@ -97,6 +96,9 @@ 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
@@ -112,9 +114,7 @@ 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( `<a href="${hrefTemplate(link + headingId)}" class="${classNames} transclusion">`
link + headingId
)}" class="${classNames} transclusion">`
); );
this.raw(displayName); this.raw(displayName);
this.tag("</a>"); this.tag("</a>");
@@ -128,18 +128,11 @@ function html(opts: HtmlOptions = {}) {
)}#toolbar=0" class="${classNames}" />` )}#toolbar=0" class="${classNames}" />`
); );
} else { } else {
const hasDimensions = alias && /^\d+(x\d+)?$/.test(alias); this.tag(
// Take the target as alt text except if alt name was provided [[target|alt text]] `<img src="${hrefTemplate(
const altText = hasDimensions || !alias ? target : alias; link
let imgAttributes = `src="${hrefTemplate( )}" alt="${displayName}" class="${classNames}" />`
link );
)}" 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(

View File

@@ -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,14 +97,10 @@ 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( const serialized = micromark("[[Wiki Link#Some Heading|Alias]]", "ascii", {
"[[Wiki Link#Some Heading|Alias]]", extensions: [syntax()],
"ascii", htmlExtensions: [html() as any], // TODO type fix
{ });
extensions: [syntax()],
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>'
@@ -138,7 +134,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", () => {
@@ -151,28 +147,6 @@ 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()],
@@ -180,7 +154,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(
@@ -215,7 +189,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", () => {
@@ -223,7 +197,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", () => {
@@ -231,7 +205,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", () => {
@@ -239,7 +213,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>");
}); });
}); });
@@ -251,7 +225,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(
@@ -277,7 +251,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(
@@ -356,5 +330,15 @@ 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>`);
});
})
}); });

View File

@@ -246,28 +246,6 @@ 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");
}); });
}); });
@@ -387,17 +365,13 @@ 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( let ast = processor.parse("[[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!:ª%@'*º$ °~./\\]]"
);
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( expect(node.data?.permalink).toEqual("li nk-w(i)th-àcèô íã_a(n)d_underline!:ª%@'*º$ °~./\\");
"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(
@@ -409,9 +383,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", () => {
@@ -612,3 +586,4 @@ describe("remark-wiki-link", () => {
}); });
}); });
}); });