Integrate flowershow packages (#923)

* [packages][m]: mv @flowershow/core package here

* [packages/core][xs]: rename to @portaljs/core

* [package.json][xs]: setup npm workspaces

* [packages/core][xs]:replace deprecated rollup executor

* [core/package.json][s]: fix mermaid versions

* [core/tsconfig][xs]: rm extends

* [core/jest.config][xs]: rm coverageDirectory

* [core/package.json][xs]: install core-js

* [packages.json][s]:use same version for all nrwl packages

* [core/.eslintrc][xs]: adjust ignorePatterns

* [core/project.json][xs]: rm publish targets

* [packages][m]: mv @flowershow/remark-wiki-link here

* [packages][m]: mv @flowershow/remark-wiki-link here

* [packages][m]: mv @flowershow/remark-embed here

* [remark-callouts/project.json][xs]: adjst test pattern

* [package.json][s]: install missing deps

* [remark-callouts][xs]: adjst fields in package.json

* [remark-callouts][s]: rm pubish targets and adjst build executor

* [remark-embed/jest.config][xs]: rm unknown option coverageDirectory

* [remark-embed][xs]: rm publish targets

* [remark-embed][s]: rename to @portaljs/remark-embed

* [remark-wiki-link/eslintrc][xs]:adjst ignorePatterns

* [package.json][xs]: install missing deps

* [remark-wiki-link/test][xs]:specify format

- also temporarily force any type on htmlExtension

* [remark-wiki-link/README][xs]: replace @flowershow with @portaljs

* [remark-wiki-link][xs]:rm old changelog

* [remark-wiki-link][xs]: adjst package.json

* [remark-wiki-link/project.json][xs]: rm publish targets

* [core][s]: rm old changelog

* [core/README][xs]:correct scope name

* [remark-callouts/README][xs]: add @portaljs to pckg name

* [remark-embed/README][xs]: add @portaljs to pckg name

* [package-lock.json][xs]: refresh after rebasing on main
This commit is contained in:
Ola Rubaj 2023-06-07 12:21:00 +02:00 committed by GitHub
parent 0b8c56bcac
commit af134cac8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
139 changed files with 10264 additions and 2303 deletions

6748
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
{
"name": "portaljs",
"workspaces": ["./packages/*"],
"workspaces": [
"./packages/*"
],
"version": "0.0.0",
"license": "MIT",
"scripts": {
@ -14,11 +16,11 @@
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.1",
"@nrwl/cypress": "15.9.2",
"@nrwl/eslint-plugin-nx": "^16.0.2",
"@nrwl/eslint-plugin-nx": "15.9.2",
"@nrwl/jest": "15.9.2",
"@nrwl/js": "15.9.2",
"@nrwl/linter": "15.9.2",
"@nrwl/next": "^15.9.2",
"@nrwl/next": "15.9.2",
"@nrwl/react": "15.9.2",
"@nrwl/rollup": "15.9.2",
"@nrwl/workspace": "15.9.2",
@ -28,13 +30,16 @@
"@swc/helpers": "~0.5.0",
"@swc/jest": "0.2.20",
"@testing-library/react": "14.0.0",
"@types/chai": "^4.3.5",
"@types/jest": "^29.4.0",
"@types/mocha": "^10.0.1",
"@types/node": "18.14.2",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@typescript-eslint/eslint-plugin": "^5.36.1",
"@typescript-eslint/parser": "^5.36.1",
"babel-jest": "^29.4.1",
"chai": "^4.3.7",
"cypress": "^12.2.0",
"eslint": "~8.15.0",
"eslint-config-next": "13.1.1",
@ -44,14 +49,21 @@
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"htmlparser2": "^9.0.0",
"jest": "^29.4.1",
"jest-environment-jsdom": "^29.4.1",
"micromark": "^3.2.0",
"mocha": "^10.2.0",
"nx": "15.9.2",
"prettier": "^2.6.2",
"react-test-renderer": "18.2.0",
"rehype-stringify": "^9.0.3",
"remark": "^14.0.3",
"swc-loader": "0.1.15",
"ts-jest": "^29.0.5",
"ts-node": "10.9.1",
"typescript": "~4.9.5"
"typescript": "~4.9.5",
"unist-util-select": "^4.0.3",
"unist-util-visit": "^4.1.2"
}
}

12
packages/core/.babelrc Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "dist/**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

3
packages/core/README.md Normal file
View File

@ -0,0 +1,3 @@
# @portaljs/core
Core Portal.JS package containing components, styles, and utils.

View File

@ -0,0 +1,9 @@
/* eslint-disable */
export default {
displayName: "core",
preset: "../../jest.preset.js",
transform: {
"^.+\\.[tj]sx?$": "babel-jest",
},
moduleFileExtensions: ["ts", "tsx", "js", "jsx"]
};

View File

@ -0,0 +1,42 @@
{
"name": "@portaljs/core",
"version": "1.0.0",
"description": "Core Portal.JS components, configs and utils.",
"repository": {
"type": "git",
"url": "git+https://github.com/datopian/portaljs.git",
"directory": "packages/core"
},
"author": "Rufus Pollock",
"license": "MIT",
"bugs": {
"url": "https://github.com/datopian/portaljs/issues"
},
"homepage": "https://github.com/datopian/portaljs#readme",
"publishConfig": {
"access": "public"
},
"type": "module",
"main": "./dist/index.js",
"dependencies": {
"@docsearch/react": "^3.3.3",
"@floating-ui/react-dom": "^1.3.0",
"@floating-ui/react-dom-interactions": "^0.13.3",
"@giscus/react": "^2.2.6",
"@headlessui/react": "^1.7.12",
"clsx": "^1.2.1",
"core-js": "^3.30.2",
"disqus-react": "^1.1.5",
"framer-motion": "^10.0.1",
"kbar": "0.1.0-beta.40",
"mdx-mermaid": "^1.3.2",
"mermaid": "^10.2.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"next": "^13.2.1",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

View File

@ -0,0 +1,45 @@
{
"name": "core",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/core/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nrwl/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "packages/core/dist",
"tsConfig": "packages/core/tsconfig.lib.json",
"project": "packages/core/package.json",
"entryFile": "packages/core/src/index.ts",
"format": ["esm"],
"generateExportsField": true,
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
"compiler": "babel",
"assets": [
{
"glob": "packages/core/README.md",
"input": ".",
"output": "."
}
]
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["packages/core/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "packages/core/jest.config.ts",
"passWithNoTests": true
}
}
}
}

View File

@ -0,0 +1,31 @@
export const defaultConfig = {
title: "Flowershow",
description: "",
showEditLink: false,
showToc: true,
showSidebar: false,
showLinkPreviews: true,
author: "",
authorLogo: "",
domain: "",
// Google analytics key e.g. G-XXXX
analytics: "",
// content source directory for markdown files
// DO NOT CHANGE THIS VALUE
// if you have your notes in another (external) directory,
// /content dir should be a symlink to that directory
content: "content",
avatarPlaceholder: "/_flowershow/avatarplaceholder.png",
contentExclude: [],
contentInclude: [],
blogDir: "blog",
peopleDir: "people",
// Theme
theme: {
default: "dark",
toggleIcon: "/_flowershow/theme-button.svg",
},
navLinks: [
// { href: '/about', name: 'About' },
],
};

View File

@ -0,0 +1 @@
export { defaultConfig } from "./default";

View File

@ -0,0 +1,3 @@
export * from "./ui";
export * from "./utils";
export * from "./config";

7
packages/core/src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
export {};
declare global {
interface Window {
gtag: any; // TODO
}
}

View File

@ -0,0 +1,24 @@
// TODO
type Props = any;
export const Avatar: React.FC<Props> = ({ name, img, href }) => {
const Component = href ? "a" : "div";
return (
<Component href={href} className="group block flex-shrink-0 mt-2">
<div className="flex items-center space-x-2">
<div>
<img
className="inline-block h-9 w-9 rounded-full"
src={img}
alt={name}
/>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-primary dark:text-primary-dark">
{name}
</p>
</div>
</div>
</Component>
);
};

View File

@ -0,0 +1 @@
export { Avatar } from "./Avatar";

View File

@ -0,0 +1,15 @@
import Link from "next/link.js";
import { forwardRef } from "react";
const BaseLink = forwardRef((props: any, ref) => {
const { href, children, ...rest } = props;
return (
<Link href={href} ref={ref} {...rest}>
{children}
</Link>
);
});
BaseLink.displayName = "BaseLink";
export { BaseLink };

View File

@ -0,0 +1,57 @@
import Link from "next/link.js";
import { Tooltip } from "../Tooltip";
import TwitterEmbed from "./TwitterEmbed";
// TODO it's a mess, move twitter embeds support to remark-embed
const TWITTER_REGEX =
/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)/;
interface Props {
href: string;
data: any;
usehook: any;
preview: boolean;
children: React.ReactNode;
className?: string;
[x: string]: unknown;
}
export const CustomLink: React.FC<Props> = ({
data,
usehook,
preview,
...props
}) => {
const { href } = props;
const isInternalLink = !href.startsWith("http");
// eslint-disable-next-line no-useless-escape
const isHeadingLink = href.startsWith("#");
const isTwitterLink = TWITTER_REGEX.test(href);
// Use next link for pages within app and <a> for external links.
// https://nextjs.org/learn/basics/navigate-between-pages/client-side
if (isInternalLink) {
if (preview && !isHeadingLink) {
return (
<Tooltip
{...props}
data={data} // TODO again, why do we pass all documents here?!
usehook={usehook}
render={(tooltipTriggerProps) => <Link {...tooltipTriggerProps} />}
/>
);
} else {
return <Link {...props} />;
}
}
if (isTwitterLink) {
return <TwitterEmbed url={href} {...props} />;
}
return (
<a target="_blank" rel="noopener noreferrer" {...props}>
{props.children}
</a>
);
};

View File

@ -0,0 +1,44 @@
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
interface Props {
defaultTheme: "dark" | "light";
toggleIcon: string;
}
export const ThemeSelector: React.FC<Props> = ({
defaultTheme,
toggleIcon,
}) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
useEffect(() => setMounted(true), []);
/** Avoid Hydration Mismatch
* https://github.com/pacocoursey/next-themes#avoid-hydration-mismatch
*/
if (!mounted) return null;
// TODO why?
if (!defaultTheme) return null;
return (
<button
type="button"
className={`
min-w-fit transition duration-500
${theme === "dark" ? "grayscale opacity-70" : ""}
`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<img
src={toggleIcon}
alt="toggle theme"
width={24}
height={24}
className="max-w-24 max-h-24"
/>
</button>
);
};

View File

@ -0,0 +1,105 @@
// TODO dark and light theme
import { useEffect, useState, useRef, RefObject } from "react";
const twitterWidgetJs = "https://platform.twitter.com/widgets.js";
enum TweetState {
LOADING,
LOADED,
FAILED,
}
interface TweetConfig {
theme: string;
}
declare global {
interface Window {
twttr: {
widgets: {
createTweet: (
id: string,
ref: RefObject<HTMLDivElement>,
options: TweetConfig
) => Promise<any>; // TODO type
load: (ref: RefObject<HTMLDivElement>) => void;
};
};
}
}
export default function TwitterEmbed({ url, ...props }) {
const ref = useRef<HTMLDivElement | null>(null);
const [tweetState, setTweetState] = useState<TweetState>(TweetState.LOADING);
const tweetId = url.split("status/").pop();
useEffect(() => {
const renderTweet = () => {
window.twttr.widgets
.createTweet(tweetId, ref.current as any, {
theme: "dark",
})
.then((el) => {
if (el) {
setTweetState(TweetState.LOADED);
} else {
setTweetState(TweetState.FAILED);
}
});
return window.twttr.widgets.load(ref.current as any);
};
if (!window.twttr) {
const script = document.createElement("script");
script.src = twitterWidgetJs;
script.async = true;
script.onload = () => renderTweet();
document.head.appendChild(script);
} else {
renderTweet();
}
}, [tweetId]);
return (
<>
{tweetState === TweetState.LOADING && (
<div className="relative my-4 w-full sm:max-w-xl bg-neutral-900 drop-shadow-md rounded-lg">
<div className="absolute flex flex-col flex-wrap break-all items-center justify-center bg-slate-700/60 w-full h-full px-4 py-2 rounded-lg top-0 left-0 z-10">
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="w-6 absolute right-4 top-4"
>
<title>Twitter</title>
<path
fill="#1DA1F2"
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
/>
</svg>
<div className="text-gray-300 font-bold my-2 italic">
{"Loading tweet..."}
</div>
</div>
<div className="p-3 space-y-4 animate-pulse">
<div className="flex items-center">
<div className="mr-2 h-10 w-10 rounded-full bg-slate-700" />
<div className="w-1/3 h-4 bg-slate-700"></div>
</div>
<div className="space-y-2">
<div className="w-2/3 h-3 bg-slate-700"></div>
<div className="w-2/3 h-3 bg-slate-700"></div>
</div>
<div className="flex space-x-4">
<div className="w-1/4 h-3 bg-slate-700"></div>
<div className="w-1/4 h-3 bg-slate-700"></div>
<div className="w-1/4 h-3 bg-slate-700"></div>
</div>
</div>
</div>
)}
<div className="twitter-tweet" ref={ref} />
</>
);
}

View File

@ -0,0 +1,3 @@
export { BaseLink } from "./BaseLink";
export { ThemeSelector } from "./ThemeSelector";
export { CustomLink } from "./CustomLink";

View File

@ -0,0 +1,21 @@
export function Avatar({ name, img, href }) {
const Component = href ? "a" : "div";
return (
<Component href={href} className="group block flex-shrink-0 mt-2">
<div className="flex items-center space-x-2">
<div>
<img
className="inline-block h-9 w-9 rounded-full"
src={img}
alt={name}
/>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-primary dark:text-primary-dark">
{name}
</p>
</div>
</div>
</Component>
);
}

View File

@ -0,0 +1,40 @@
import { Card } from "../Card";
import { formatDate } from "../../utils/formatDate";
import { Blog } from "../types";
interface Props {
blog: Blog;
}
export const BlogItem: React.FC<Props> = ({ blog }) => {
return (
<article className="blogitem md:grid md:grid-cols-4 md:items-baseline">
<Card className="blogitem-card md:col-span-3">
<Card.Title className="blogitem-title" href={`${blog.urlPath}`}>
{blog.title}
</Card.Title>
<Card.Eyebrow
as="time"
dateTime={blog.date}
className="blogitem-date md:hidden"
decorate
>
{formatDate(blog.date)}
</Card.Eyebrow>
{blog.description && (
<Card.Description className="blogitem-descr">
{blog.description}
</Card.Description>
)}
<Card.Cta className="blogitem-cta">Read article</Card.Cta>
</Card>
<Card.Eyebrow
as="time"
dateTime={blog.date}
className="blogitem-date mt-1 hidden md:block"
>
{formatDate(blog.date)}
</Card.Eyebrow>
</article>
);
};

View File

@ -0,0 +1,36 @@
import { useState } from "react";
import { BlogItem } from "./BlogItem";
const BLOGS_LOAD_COUNT = 10;
// TODO types
export const BlogsList: React.FC<any> = ({ blogs }) => {
const [blogsCount, setBlogsCount] = useState(BLOGS_LOAD_COUNT);
const handleLoadMore = () => {
setBlogsCount((prevCount) => prevCount + BLOGS_LOAD_COUNT);
};
return (
<>
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
<div className="flex flex-col space-y-16">
{blogs.slice(0, blogsCount).map((blog) => {
return <BlogItem key={blog.urlPath} blog={blog} />;
})}
</div>
</div>
{blogs.length > blogsCount && (
<div className="text-center pt-20">
<button
onClick={handleLoadMore}
type="button"
className="inline-flex items-center rounded border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-200 shadow-sm hover:bg-gray-50/10 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Show more
</button>
</div>
)}
</>
);
};

View File

@ -0,0 +1 @@
export { BlogsList } from "./BlogsList";

View File

@ -0,0 +1,38 @@
/* eslint import/no-default-export: off */
import { formatDate } from "../../utils/formatDate";
import { Avatar } from "../Avatar";
// TODO
type Props = any;
export const BlogLayout: React.FC<Props> = ({ children, ...frontMatter }) => {
const { title, date, authors } = frontMatter;
return (
<article className="docs prose prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-code:text-primary dark:prose-code:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark prose text-primary dark:text-primary-dark prose-headings:font-headings dark:prose-invert prose-a:break-words mx-auto p-6">
<header>
<div className="mb-4 flex-col items-center">
{title && <h1 className="flex justify-center">{title}</h1>}
{date && (
<p className="text-sm text-zinc-400 dark:text-zinc-500 flex justify-center">
<time dateTime={date}>{formatDate(date)}</time>
</p>
)}
{authors && (
<div className="flex flex-wrap not-prose items-center space-x-6 space-y-3 justify-center">
{authors.map(({ name, avatar, urlPath }) => (
<Avatar
key={urlPath || name}
name={name}
img={avatar}
href={urlPath ? `/${urlPath}` : undefined}
/>
))}
</div>
)}
</div>
</header>
<section>{children}</section>
</article>
);
};

View File

@ -0,0 +1 @@
export { BlogLayout } from "./BlogLayout";

View File

@ -0,0 +1,170 @@
// import Link from 'next/link'
import clsx from "clsx";
import { ChevronRightIcon } from "../Icons";
interface CardProps extends React.PropsWithChildren {
as?: React.ElementType;
className?: string;
}
interface CardLinkProps extends React.PropsWithChildren {
href?: string;
className?: string;
}
interface CardTitleProps extends React.PropsWithChildren {
as?: React.ElementType;
href?: string;
className?: string;
}
interface CardDescriptionProps extends React.PropsWithChildren {
className?: string;
}
interface CardCtaProps extends React.PropsWithChildren {
className?: string;
}
interface CardEyebrowProps extends React.PropsWithChildren {
as?: React.ElementType;
decorate?: boolean;
className?: string;
[x: string]: unknown;
}
type Card = React.FC<CardProps> & { Link: React.FC<CardLinkProps> } & {
Title: React.FC<CardTitleProps>;
} & { Description: React.FC<CardDescriptionProps> } & {
Cta: React.FC<CardCtaProps>;
} & { Eyebrow: React.FC<CardEyebrowProps> };
export const Card: Card = ({ children, as: Component = "div", className }) => {
return (
<Component
className={clsx(className, "group relative flex flex-col items-start")}
>
{children}
</Component>
);
};
Card.Link = function CardLink({ children, href, className, ...props }) {
// <Link {...props}>
// <span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
// <span className="relative z-10">{children}</span>
// </Link>
return (
<>
<div className="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-slate-800/75 sm:-inset-x-6 sm:rounded-2xl" />
<a href={href} className={className} {...props}>
<span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
<span className="relative z-10">{children}</span>
</a>
</>
);
};
Card.Title = function CardTitle({
as: Component = "h2",
href,
children,
className,
}) {
return (
<Component
className={clsx(
className,
"text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100"
)}
>
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
</Component>
);
};
Card.Description = function CardDescription({ children, className }) {
return (
<p
className={clsx(
className,
"relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400"
)}
>
{children}
</p>
);
};
Card.Cta = function CardCta({ children, className }) {
return (
<div
aria-hidden="true"
className={clsx(
className,
"relative z-10 mt-4 flex items-center text-sm font-medium text-secondary dark:text-secondary-dark"
)}
>
{children}
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
</div>
);
};
/* Card.Avatar = function CardAvatar({ name, src, href }) {
* return (
* <a href={href} className="group block flex-shrink-0 mt-2">
* <div className="flex items-center">
* <div>
* {src ? (
* <img
* className="inline-block h-9 w-9 rounded-full"
* src={src}
* alt={name}
* />
* ) : (
* <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-gray-500">
* <span className="text-xs font-medium leading-none text-white">
* {initialsFromName(name)}
* </span>
* </span>
* )}
* </div>
* <div className="ml-3">
* <p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
* {name}
* </p>
* </div>
* </div>
* </a>
* );
* }; */
Card.Eyebrow = function CardEyebrow({
as: Component = "p",
decorate = false,
className,
children,
...props
}) {
return (
<Component
className={clsx(
className,
"relative z-10 order-first mb-3 flex items-center text-sm text-zinc-400 dark:text-zinc-500",
decorate && "pl-3.5"
)}
{...props}
>
{decorate && (
<span
className="absolute inset-y-0 left-0 flex items-center"
aria-hidden="true"
>
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
</span>
)}
{children}
</Component>
);
};

View File

@ -0,0 +1 @@
export { Card } from "./Card";

View File

@ -0,0 +1,25 @@
import { DiscussionEmbed } from "disqus-react";
export interface DisqusConfig {
provider: "disqus";
pages?: Array<string>;
config: {
shortname: string;
};
}
export type DisqusProps = DisqusConfig["config"] & {
slug?: string;
};
export const Disqus: React.FC<DisqusProps> = ({ shortname, slug }) => {
return (
<DiscussionEmbed
shortname={shortname}
config={{
url: window?.location?.href,
identifier: slug,
}}
/>
);
};

View File

@ -0,0 +1,53 @@
import Giscus, { BooleanString, Mapping, Repo } from "@giscus/react";
import { useTheme } from "next-themes";
export interface GiscusConfig {
provider: "giscus";
pages?: Array<string>;
config: {
theme?: string;
mapping: Mapping;
repo: Repo;
repositoryId: string;
category: string;
categoryId: string;
reactions: BooleanString;
metadata: BooleanString;
inputPosition?: string;
lang?: string;
};
}
export type GiscusProps = GiscusConfig["config"];
export const GiscusReactComponent: React.FC<GiscusProps> = ({
repo,
repositoryId,
category,
categoryId,
reactions = "0",
metadata = "0",
mapping = "pathname",
theme = "light",
}) => {
const { theme: nextTheme, resolvedTheme } = useTheme();
const commentsTheme =
nextTheme === "dark" || resolvedTheme === "dark"
? "transparent_dark"
: theme;
return (
<Giscus
repo={repo}
repoId={repositoryId}
category={category}
categoryId={categoryId}
mapping={mapping}
inputPosition="top"
reactionsEnabled={reactions}
emitMetadata={metadata}
// TODO: remove transparent_dark after theme toggle fix
theme={nextTheme ? commentsTheme : "transparent_dark"}
/>
);
};

View File

@ -0,0 +1,59 @@
import { useEffect, useCallback } from "react";
import { useTheme } from "next-themes";
export interface UtterancesConfig {
provider: "utterances";
pages?: Array<string>;
config: {
theme?: string;
repo: string;
label: string;
issueTerm: string;
};
}
export type UtterancesProps = UtterancesConfig["config"];
export const Utterances: React.FC<UtterancesProps> = ({
repo,
label = "comments",
issueTerm = "pathname",
theme = "github-light",
}) => {
const { theme: nextTheme, resolvedTheme } = useTheme();
// TODO: remove preferred-color-scheme after theme toggle fix
const commentsTheme = nextTheme
? nextTheme === "dark" || resolvedTheme === "dark"
? "github-dark"
: theme
: "preferred-color-scheme";
const COMMENTS_ID = "comments-container";
const LoadComments = useCallback(() => {
const script = document.createElement("script");
script.src = "https://utteranc.es/client.js";
script.setAttribute("repo", repo);
script.setAttribute("issue-term", issueTerm);
script.setAttribute("label", label);
script.setAttribute("theme", commentsTheme);
script.setAttribute("crossorigin", "anonymous");
script.async = true;
const comments = document.getElementById(COMMENTS_ID);
if (comments) comments.appendChild(script);
return () => {
const comments = document.getElementById(COMMENTS_ID);
if (comments) comments.innerHTML = "";
};
}, [commentsTheme, issueTerm]);
// Reload on theme change
useEffect(() => {
LoadComments();
}, [LoadComments]);
// Added `relative` to fix a weird bug with `utterances-frame` position
return <div className="utterances-frame relative" id={COMMENTS_ID} />;
};

View File

@ -0,0 +1,53 @@
import dynamic from "next/dynamic.js";
import { GiscusReactComponent, GiscusConfig, GiscusProps } from "./Giscus";
import { Utterances, UtterancesConfig, UtterancesProps } from "./Utterances";
import { Disqus, DisqusConfig, DisqusProps } from "./Disqus";
export type CommentsConfig = GiscusConfig | UtterancesConfig | DisqusConfig;
export interface CommentsProps {
commentsConfig: CommentsConfig;
slug?: string;
}
const GiscusComponent = dynamic<GiscusProps>(
() => {
return import("./Giscus").then((mod) => mod.GiscusReactComponent);
},
{ ssr: false }
);
const UtterancesComponent = dynamic<UtterancesProps>(
() => {
return import("./Utterances").then((mod) => mod.Utterances);
},
{ ssr: false }
);
const DisqusComponent = dynamic<DisqusProps>(
() => {
return import("./Disqus").then((mod) => mod.Disqus);
},
{ ssr: false }
);
export const Comments = ({ commentsConfig, slug }: CommentsProps) => {
switch (commentsConfig.provider) {
case "giscus":
return <GiscusComponent {...commentsConfig.config} />;
case "utterances":
return <UtterancesComponent {...commentsConfig.config} />;
case "disqus":
return <DisqusComponent slug={slug} {...commentsConfig.config} />;
}
};
export { GiscusReactComponent, Utterances, Disqus };
export type {
GiscusConfig,
GiscusProps,
UtterancesConfig,
UtterancesProps,
DisqusConfig,
DisqusProps,
};

View File

@ -0,0 +1,22 @@
/* eslint import/no-default-export: off */
import { formatDate } from "../../utils/formatDate";
// TODO types
export const DocsLayout: React.FC<any> = ({ children, ...frontMatter }) => {
const { title, created } = frontMatter;
return (
<article className="docs prose prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-code:text-primary dark:prose-code:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark prose text-primary dark:text-primary-dark dark:prose-invert prose-headings:font-headings prose-a:break-words mx-auto">
<header>
<div className="mb-6">
{created && (
<p className="text-sm text-zinc-400 dark:text-zinc-500">
<time dateTime={created}>{formatDate(created)}</time>
</p>
)}
{title && <h1>{title}</h1>}
</div>
</header>
<section>{children}</section>
</article>
);
};

View File

@ -0,0 +1 @@
export { DocsLayout } from "./Docs";

View File

@ -0,0 +1,12 @@
export const ChevronRightIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
<path
d="M6.75 5.75 9.25 8l-2.5 2.25"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@ -0,0 +1,14 @@
export const CloseIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
strokeWidth="2"
strokeLinecap="round"
{...props}
>
<path d="M5 5l14 14M19 5l-14 14" />
</svg>
);
};

View File

@ -0,0 +1,14 @@
export const DiscordIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
{...props}
>
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z" />
</svg>
);
};

View File

@ -0,0 +1,7 @@
export const GitHubIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg aria-hidden="true" viewBox="0 0 16 16" {...props}>
<path d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" />
</svg>
);
};

View File

@ -0,0 +1,14 @@
export const MenuIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
strokeWidth="2"
strokeLinecap="round"
{...props}
>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
};

View File

@ -0,0 +1,7 @@
export const SearchIcon: React.FC<{ [x: string]: unknown }> = (props) => {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
</svg>
);
};

View File

@ -0,0 +1,6 @@
export { GitHubIcon } from "./GitHubIcon";
export { DiscordIcon } from "./DiscordIcon";
export { MenuIcon } from "./MenuIcon";
export { CloseIcon } from "./CloseIcon";
export { SearchIcon } from "./SearchIcon";
export { ChevronRightIcon } from "./ChevronRightIcon";

View File

@ -0,0 +1,30 @@
export const EditThisPage = ({ url }: { url: string }) => {
return (
<div className="mb-10 prose dark:prose-invert p-6 mx-auto">
<a
className="flex no-underline font-semibold justify-center"
href={url}
target="_blank"
rel="noopener noreferrer"
>
Edit this page
<span className="mx-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</span>
</a>
</div>
);
};

View File

@ -0,0 +1,61 @@
import Link from "next/link.js";
import { AuthorConfig, NavLink } from "../types";
interface Props {
links: Array<NavLink>;
author: AuthorConfig;
}
export const Footer: React.FC<Props> = ({ links, author }) => {
return (
<footer className="bg-background dark:bg-background-dark prose dark:prose-invert max-w-none flex flex-col items-center justify-center w-full h-auto pt-10 pb-20">
<div className="flex w-full flex-wrap justify-center">
{links.map((item) => (
<Link
key={item.href}
href={item.href}
className="inline-flex items-center mx-4 px-1 pt-1 font-regular hover:text-slate-300 no-underline"
>
{/* TODO aria-current={item.current ? "page" : undefined} */}
{item.name}
</Link>
))}
</div>
<p className="flex items-center justify-center">
Created by
<a
href={author.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center no-underline"
>
{author.logo && (
<img
src={author.logo}
alt={author.name}
className="my-0 mx-1 h-6 block"
/>
)}
{author.name}
</a>
</p>
<p className="flex items-center justify-center">
Made with
<a
href="https://flowershow.app/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center no-underline"
>
<img
src="https://flowershow.app/images/logo.svg"
alt="Flowershow"
className="my-0 mx-1 h-6 block"
/>
Flowershow
</a>
</p>
</footer>
);
};

View File

@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import Head from "next/head.js";
import { NextRouter, useRouter } from "next/router.js";
import clsx from "clsx";
import { useTableOfContents } from "./useTableOfContents";
import { collectHeadings } from "../../utils";
import { Nav } from "../Nav";
import { SiteToc, NavItem, NavGroup } from "../SiteToc";
import { Comments, CommentsConfig } from "../Comments";
import { Footer } from "./Footer";
import { EditThisPage } from "./EditThisPage";
import { TableOfContents, TocSection } from "./TableOfContents";
import { NavConfig, ThemeConfig } from "../Nav";
import { AuthorConfig } from "../types";
interface Props extends React.PropsWithChildren {
showComments: boolean;
showEditLink: boolean;
showSidebar: boolean;
showToc: boolean;
nav: NavConfig;
author: AuthorConfig;
theme: ThemeConfig;
urlPath: string;
commentsConfig: CommentsConfig;
siteMap: Array<NavItem | NavGroup>;
editUrl?: string;
}
export const Layout: React.FC<Props> = ({
children,
nav,
author,
theme,
showEditLink,
showToc,
showSidebar,
urlPath,
showComments,
commentsConfig,
editUrl,
siteMap,
}) => {
const [isScrolled, setIsScrolled] = useState(false);
const [tableOfContents, setTableOfContents] = useState<TocSection[]>([]);
const currentSection = useTableOfContents(tableOfContents);
const router: NextRouter = useRouter();
useEffect(() => {
if (!showToc) return;
const headingNodes: NodeListOf<HTMLHeadingElement> =
document.querySelectorAll("h1,h2,h3");
const toc = collectHeadings(headingNodes);
setTableOfContents(toc ?? []);
}, [router.asPath, showToc]); // update table of contents on route change with next/link
useEffect(() => {
function onScroll() {
setIsScrolled(window.scrollY > 0);
}
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
};
}, []);
return (
<>
<Head>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💐</text></svg>"
/>
<meta charSet="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<div className="min-h-screen bg-background dark:bg-background-dark">
{/* NAVBAR */}
<div
className={clsx(
"sticky top-0 z-50 w-full",
isScrolled
? "dark:bg-background-dark/95 bg-background/95 backdrop-blur [@supports(backdrop-filter:blur(0))]:dark:bg-background-dark/75"
: "dark:bg-background-dark bg-background"
)}
>
<div className="max-w-8xl mx-auto p-4 md:px-8">
<Nav
title={nav.title}
logo={nav.logo}
links={nav.links}
search={nav.search}
social={nav.social}
defaultTheme={theme.defaultTheme}
themeToggleIcon={theme.themeToggleIcon}
>
{showSidebar && <SiteToc currentPath={urlPath} nav={siteMap} />}
</Nav>
</div>
</div>
{/* wrapper for sidebar, main content and ToC */}
<div
className={clsx(
"max-w-8xl mx-auto px-4 md:px-8",
showSidebar && "lg:ml-[18rem]",
showToc && "xl:mr-[18rem]"
)}
>
{/* SIDEBAR */}
{showSidebar && (
<div className="hidden lg:block fixed z-20 w-[18rem] top-[4.6rem] right-auto bottom-0 left-[max(0px,calc(50%-44rem))] pt-8 pl-8 overflow-y-auto">
<SiteToc currentPath={urlPath} nav={siteMap} />
</div>
)}
{/* MAIN CONTENT & FOOTER */}
<main className="mx-auto pt-8">
{children}
{/* EDIT THIS PAGE LINK */}
{showEditLink && editUrl && <EditThisPage url={editUrl} />}
{/* PAGE COMMENTS */}
{showComments && (
<div
className="prose mx-auto pt-6 pb-6 text-center text-gray-700 dark:text-gray-300"
id="comment"
>
{<Comments commentsConfig={commentsConfig} slug={urlPath} />}
</div>
)}
</main>
<Footer links={nav.links} author={author} />
{/** TABLE OF CONTENTS */}
{showToc && tableOfContents.length > 0 && (
<div className="hidden xl:block fixed z-20 w-[18rem] top-[4.6rem] bottom-0 right-[max(0px,calc(50%-44rem))] left-auto pt-8 pr-8 overflow-y-auto">
<TableOfContents
tableOfContents={tableOfContents}
currentSection={currentSection}
/>
</div>
)}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,90 @@
import Link from "next/link.js";
export interface TocSection {
id: string;
title: string;
level: string;
children?: any;
}
interface Props {
tableOfContents: TocSection[];
currentSection: string;
}
export const TableOfContents: React.FC<Props> = ({
tableOfContents,
currentSection,
}) => {
function isActiveSection(section) {
if (section.id === currentSection) {
return true;
}
if (!section.children) {
return false;
}
return section.children.findIndex(isActiveSection) > -1;
}
return (
<nav aria-labelledby="on-this-page-title">
<h2 className="font-display text-md font-medium text-slate-900 dark:text-white">
On this page
</h2>
<ol className="mt-4 space-y-3 text-sm">
{tableOfContents.map((section) => (
<li key={section.id}>
<h3>
<Link
href={`#${section.id}`}
className={
isActiveSection(section)
? "text-secondary dark:text-secondary-dark"
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
}
>
{section.title}
</Link>
</h3>
{section.children && section.children.length > 0 && (
<ol className="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
{section.children.map((subSection) => (
<li key={subSection.id}>
<Link
href={`#${subSection.id}`}
className={
isActiveSection(subSection)
? "text-secondary dark:text-secondary-dark"
: "hover:text-slate-600 dark:hover:text-slate-300"
}
>
{subSection.title}
</Link>
{subSection.children && subSection.children.length > 0 && (
<ol className="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
{subSection.children.map((thirdSection) => (
<li key={thirdSection.id}>
<Link
href={`#${thirdSection.id}`}
className={
isActiveSection(thirdSection)
? "text-secondary dark:text-secondary-dark"
: "hover:text-slate-600 dark:hover:text-slate-300"
}
>
{thirdSection.title}
</Link>
</li>
))}
</ol>
)}
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
);
};

View File

@ -0,0 +1,5 @@
export { EditThisPage } from "./EditThisPage";
export { Layout } from "./Layout";
export { useTableOfContents } from "./useTableOfContents";
export { TableOfContents, TocSection } from "./TableOfContents";
export { Footer } from "./Footer";

View File

@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
// TODO types
export const useTableOfContents = (tableOfContents) => {
const [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id);
const getHeadings = useCallback((toc) => {
return toc
.flatMap((node) => [
node.id,
...node.children.flatMap((child) => [
child.id,
...child.children.map((subChild) => subChild.id),
]),
])
.map((id) => {
const el = document.getElementById(id);
if (!el) return null;
const style = window.getComputedStyle(el);
const scrollMt = parseFloat(style.scrollMarginTop);
const top = window.scrollY + el.getBoundingClientRect().top - scrollMt;
return { id, top };
})
.filter((el) => !!el);
}, []);
useEffect(() => {
if (tableOfContents.length === 0) return;
const headings = getHeadings(tableOfContents);
function onScroll() {
const top = window.scrollY + 4.5;
let current = headings[0].id;
headings.forEach((heading) => {
if (top >= heading.top) {
current = heading.id;
}
return current;
});
setCurrentSection(current);
}
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener("scroll", onScroll);
};
}, [getHeadings, tableOfContents]);
return currentSection;
};

View File

@ -0,0 +1,25 @@
import dynamic from "next/dynamic.js";
// import { useTheme } from "next-themes";
import type { MermaidProps } from "mdx-mermaid/lib/Mermaid";
// import type { Config } from "mdx-mermaid/lib/config.model";
const MdxMermaid = dynamic(
() => import("mdx-mermaid/lib/Mermaid").then((res) => res.Mermaid),
{ ssr: false }
);
export const Mermaid: React.FC<MermaidProps> = ({ ...props }) => {
// TODO: add light and dark theme configs
// currently Mermaid component doesn't render if configs are passed as props.
// const { theme } = useTheme()
// const config: Config = {
// mermaid: {
// fontFamily: "inherit",
// theme: theme
// }
// }
return <MdxMermaid {...props} />;
};

View File

@ -0,0 +1 @@
export { Mermaid } from "./Mermaid";

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { ThemeSelector } from "../Base";
import { SearchContext, SearchField } from "../Search";
import { NavMobile } from "./NavMobile";
import { NavItem } from "./NavItem";
import { NavTitle } from "./NavTitle";
import { NavSocial } from "./NavSocial";
import { NavLink, SocialLink, SearchProviderConfig } from "../types";
export interface ThemeConfig {
defaultTheme: "dark" | "light";
themeToggleIcon: string;
}
export interface NavConfig {
title: string;
logo?: string;
version?: string;
links: Array<NavLink>;
search?: SearchProviderConfig;
social?: Array<SocialLink>;
}
interface Props extends NavConfig, ThemeConfig, React.PropsWithChildren {}
export const Nav: React.FC<Props> = ({
children,
title,
logo,
version,
links,
search,
social,
defaultTheme,
themeToggleIcon,
}) => {
const [modifierKey, setModifierKey] = useState<string>();
const [Search, setSearch] = useState<any>(); // TODO types
useEffect(() => {
const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent);
setModifierKey(isMac ? "⌘" : "Ctrl ");
}, []);
useEffect(() => {
if (search) {
setSearch(SearchContext(search.provider));
}
}, [search]);
return (
<nav className="flex justify-between">
{/* Mobile navigation */}
<div className="mr-2 sm:mr-4 flex lg:hidden">
<NavMobile links={links}>{children}</NavMobile>
</div>
{/* Non-mobile navigation */}
<div className="flex flex-none items-center">
<NavTitle title={title} logo={logo} version={version} />
{links && (
<div className="hidden lg:flex ml-8 mr-6 sm:mr-8 md:mr-0">
{links.map((link) => (
<NavItem link={link} key={link.name} />
))}
</div>
)}
</div>
{/* Search field and social links */}
<div className="relative flex items-center basis-auto justify-end gap-6 xl:gap-8 md:shrink w-full">
{Search && (
<Search>
{({ query }: any) => (
<SearchField modifierKey={modifierKey} onOpen={query?.toggle} />
)}
</Search>
)}
<ThemeSelector
defaultTheme={defaultTheme}
toggleIcon={themeToggleIcon}
/>
{social && <NavSocial links={social} />}
</div>
</nav>
);
};

View File

@ -0,0 +1,21 @@
import { Menu } from "@headlessui/react";
import Link from "next/link.js";
import { BaseLink } from "../Base";
import { NavLink } from "../types";
interface Props {
link: NavLink;
}
export const NavItem: React.FC<Props> = ({ link }) => {
return (
<Menu as="div" className="relative">
<Link
href={link.href}
className="text-slate-500 inline-flex items-center mr-2 px-1 pt-1 text-sm font-medium hover:text-slate-600"
>
{link.name}
</Link>
</Menu>
);
};

View File

@ -0,0 +1,116 @@
import { Dialog, Menu } from "@headlessui/react";
import Link from "next/link.js";
import { useRouter } from "next/router.js";
import { useEffect, useState } from "react";
import { SearchContext, SearchField } from "../Search";
import { MenuIcon, CloseIcon } from "../Icons";
import { NavLink, SearchProviderConfig } from "../types";
interface Props extends React.PropsWithChildren {
author?: string;
links?: Array<NavLink>;
search?: SearchProviderConfig;
}
// TODO why mobile navigation only accepts author and regular nav accepts different things like title, logo, version
export const NavMobile: React.FC<Props> = ({
children,
links,
search,
author,
}) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [Search, setSearch] = useState<any>(); // TODO types
useEffect(() => {
if (!isOpen) return;
function onRouteChange() {
setIsOpen(false);
}
router.events.on("routeChangeComplete", onRouteChange);
router.events.on("routeChangeError", onRouteChange);
return () => {
router.events.off("routeChangeComplete", onRouteChange);
router.events.off("routeChangeError", onRouteChange);
};
}, [router, isOpen]);
useEffect(() => {
if (search) {
setSearch(SearchContext(search.provider));
}
}, [search]);
return (
<>
<button
type="button"
onClick={() => setIsOpen(true)}
className="relative"
aria-label="Open navigation"
>
<MenuIcon className="h-6 w-6 stroke-slate-500" />
</button>
<Dialog
open={isOpen}
onClose={setIsOpen}
className="fixed inset-0 z-50 flex items-start overflow-y-auto bg-background-dark/50 pr-10 backdrop-blur lg:hidden"
aria-label="Navigation"
>
<Dialog.Panel className="relative min-h-full w-full max-w-xs bg-background px-4 pt-5 pb-12 dark:bg-background-dark sm:px-6">
<div className="flex items-center mb-6">
<button
type="button"
onClick={() => setIsOpen(false)}
aria-label="Close navigation"
>
<CloseIcon className="h-6 w-6 stroke-slate-500" />
</button>
<Link
href="/"
className="ml-6"
aria-label="Home page"
legacyBehavior
>
{/* <Logomark className="h-9 w-9" /> */}
<div className="font-extrabold text-primary dark:text-primary-dark text-2xl ml-6">
{author}
</div>
</Link>
</div>
{Search && (
<Search>
{({ query }: any) => <SearchField mobile onOpen={query.toggle} />}
</Search>
)}
{links && (
<ul className="mt-2 space-y-2 border-l-2 border-slate-100 dark:border-slate-800 lg:mt-4 lg:space-y-4 lg:border-slate-200">
{links.map((link) => (
<Menu as="div" key={link.name} className="relative">
<Menu.Button>
<li key={link.href}>
<Link
href={link.href}
className={`
block w-full pl-3.5 before:pointer-events-none before:absolute before:-left-1 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300`}
>
{link.name}
</Link>
</li>
</Menu.Button>
</Menu>
))}
</ul>
)}
{/* <div className="pt-6 border border-t-2">
{children}
</div> */}
</Dialog.Panel>
</Dialog>
</>
);
};

View File

@ -0,0 +1,27 @@
import Link from "next/link.js";
import { GitHubIcon, DiscordIcon } from "../Icons";
import { SocialLink, SocialPlatform } from "../types";
interface Props {
links: Array<SocialLink>;
}
const icons: { [K in SocialPlatform]: React.FC<any> } = {
github: GitHubIcon,
discord: DiscordIcon,
};
export const NavSocial: React.FC<Props> = ({ links }) => {
return (
<>
{links.map(({ label, href }) => {
const Icon = icons[label];
return (
<Link key={label} href={href} aria-label={label} className="group">
<Icon className="h-6 w-6 dark:fill-slate-400 group-hover:fill-slate-500 dark:group-hover:fill-slate-300" />
</Link>
);
})}
</>
);
};

View File

@ -0,0 +1,27 @@
import Link from "next/link.js";
interface Props {
title: string;
logo?: string;
version?: string;
}
export const NavTitle: React.FC<Props> = ({ title, logo, version }) => {
return (
<Link
href="/"
aria-label="Home page"
className="flex items-center font-extrabold text-xl sm:text-2xl text-slate-900 dark:text-white"
>
{logo && (
<img src={logo} alt={title} className="nav-logo mr-1 fill-white" />
)}
{title && <span>{title}</span>}
{version && (
<div className="mx-2 rounded-full border border-slate-500 py-1 px-3 text-xs text-slate-500">
{version}
</div>
)}
</Link>
);
};

View File

@ -0,0 +1 @@
export { Nav, NavConfig, ThemeConfig } from "./Nav";

View File

@ -0,0 +1,74 @@
import { useRef, useState } from "react";
interface Props extends React.PropsWithChildren {
className?: string;
}
export const Pre: React.FC<Props> = ({ children, ...props }) => {
const ref = useRef<any>(); // TODO type
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const onEnter = () => {
setHovered(true);
};
const onExit = () => {
setHovered(false);
setCopied(false);
};
const onCopy = () => {
setCopied(true);
navigator.clipboard.writeText(ref.current.textContent);
setTimeout(() => {
setCopied(false);
}, 2000);
};
return (
<div
ref={ref}
onMouseEnter={onEnter}
onMouseLeave={onExit}
className="relative"
>
{hovered && (
<button
aria-label="Copy code"
type="button"
className={`absolute right-2 top-2 h-6 w-6 rounded border bg-gray-700 p-1 ease-in-out duration-100 ${
copied
? "border-green-400 focus:border-green-400 focus:outline-none"
: "border-slate-300"
}`}
onClick={onCopy}
>
<svg
aria-hidden="true"
viewBox="-2 -2 20 20"
fill="currentColor"
className={copied ? "text-green-400" : "text-slate-300"}
>
{copied ? (
<path
fillRule="evenodd"
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
/>
) : (
<>
<path
fillRule="evenodd"
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
/>
<path
fillRule="evenodd"
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
/>
</>
)}
</svg>
</button>
)}
<pre>{children}</pre>
</div>
);
};

View File

@ -0,0 +1 @@
export { Pre } from "./Pre";

View File

@ -0,0 +1,132 @@
import * as docsearch from "@docsearch/react";
import Head from "next/head.js";
import Link from "next/link.js";
import { useRouter } from "next/router.js";
import { createContext, useCallback, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
const { useDocSearchKeyboardEvents } = docsearch;
let DocSearchModal: any = null;
function Hit({ hit, children }) {
return <Link href={hit.url}>{children}</Link>;
}
export const AlgoliaSearchContext = createContext({});
export function AlgoliaSearchProvider({ children, config }) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState(undefined);
const importDocSearchModalIfNeeded = useCallback(async () => {
if (DocSearchModal) {
return Promise.resolve();
}
const [{ DocSearchModal: Modal }] = await Promise.all([docsearch]);
// eslint-disable-next-line
DocSearchModal = Modal;
}, [DocSearchModal]);
const onOpen = useCallback(() => {
importDocSearchModalIfNeeded().then(() => {
setIsOpen(true);
});
}, [importDocSearchModalIfNeeded, setIsOpen]);
const onClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
const onInput = useCallback(
(event) => {
importDocSearchModalIfNeeded().then(() => {
setIsOpen(true);
setInitialQuery(event.key);
});
},
[importDocSearchModalIfNeeded, setIsOpen, setInitialQuery]
);
// web accessibility
// https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/keyboard-navigation/
const navigator = useRef({
navigate({ itemUrl }) {
// Algolia results could contain URL's from other domains which cannot
// be served through history and should navigate with window.location
const isInternalLink = itemUrl.startsWith("/");
const isAnchorLink = itemUrl.startsWith("#");
if (!isInternalLink && !isAnchorLink) {
window.location.href = itemUrl;
} else {
router.push(itemUrl);
}
},
}).current;
// https://docsearch.algolia.com/docs/api#transformitems
const transformItems = (items) =>
items.map((item) => {
// If Algolia contains a external domain, we should navigate without
// relative URL
const isInternalLink = item.url.startsWith("/");
const isAnchorLink = item.url.startsWith("#");
if (!isInternalLink && !isAnchorLink) {
return item;
}
// We transform the absolute URL into a relative URL.
const url = new URL(item.url);
return {
...item,
// url: withBaseUrl(`${url.pathname}${url.hash}`),
url: `${url.pathname}${url.hash}`,
};
});
// ).current;
useDocSearchKeyboardEvents({
isOpen,
onOpen,
onClose,
onInput,
});
const providerValue = useMemo(
() => ({ query: { setSearch: setInitialQuery, toggle: onOpen } }),
[setInitialQuery, onOpen]
);
return (
<AlgoliaSearchContext.Provider value={providerValue}>
<Head>
{/* This hints the browser that the website will load data from Algolia,
and allows it to preconnect to the DocSearch cluster. It makes the first
query faster, especially on mobile. */}
<link
rel="preconnect"
href={`https://${config.appId}-dsn.algolia.net`}
crossOrigin="anonymous"
/>
</Head>
{children}
{isOpen &&
DocSearchModal &&
createPortal(
<DocSearchModal
onClose={onClose}
initialScrollY={window.scrollY}
initialQuery={initialQuery}
navigator={navigator}
transformItems={transformItems}
hitComponent={Hit}
placeholder={config.placeholder ?? "Search"}
{...config}
/>,
document.body
)}
</AlgoliaSearchContext.Provider>
);
}

View File

@ -0,0 +1,33 @@
import router from "next/router.js";
import { Action } from "kbar";
import { KBarModal } from "./KBarModal";
export const KBarSearchProvider = ({ config, children }) => {
const defaultActions = config?.defaultActions;
const searchDocumentsPath = "/search.json";
let startingActions: Action[] = [
{
id: "homepage",
name: "Homepage",
keywords: "",
section: "Home",
perform: () => router.push("/"),
},
];
if (defaultActions && Array.isArray(defaultActions))
startingActions = [...startingActions, ...defaultActions];
return KBarModal ? (
<KBarModal
startingActions={startingActions}
searchDocumentsPath={searchDocumentsPath}
>
{children}
</KBarModal>
) : (
children
);
};

View File

@ -0,0 +1,21 @@
import { KBarProvider, Action } from "kbar";
import { Portal } from "./KBarPortal";
interface Props extends React.PropsWithChildren {
searchDocumentsPath: string;
startingActions?: Action[];
}
export const KBarModal: React.FC<Props> = ({
searchDocumentsPath,
startingActions,
children,
}) => {
return (
<KBarProvider actions={startingActions}>
<Portal searchDocumentsPath={searchDocumentsPath} />
{children}
</KBarProvider>
);
};

View File

@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import {
Action,
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
useRegisterActions,
} from "kbar";
import { kbarActionsFromDocuments } from "./kbarActionsFromDocuments";
interface Props {
searchDocumentsPath: string;
}
export const Portal: React.FC<Props> = ({ searchDocumentsPath }) => {
const [searchActions, setSearchActions] = useState<Action[]>([]);
useEffect(() => {
const fetchData = async () => {
const res = await fetch(searchDocumentsPath);
const json = await res.json();
const actions = kbarActionsFromDocuments(json);
setSearchActions(actions);
};
fetchData();
}, [searchDocumentsPath]);
useRegisterActions(searchActions, [searchActions]);
return (
<KBarPortal>
<KBarPositioner className="bg-gray-300/50 p-4 backdrop-blur backdrop-filter dark:bg-black/50">
<KBarAnimator className="w-full max-w-xl">
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center space-x-4 p-4">
<span className="block w-5">
<svg
className="text-gray-400 dark:text-gray-300"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</span>
<KBarSearch
defaultPlaceholder="Search"
className="h-8 w-full bg-transparent text-slate-600 placeholder-slate-400 focus:outline-none dark:text-slate-200 dark:placeholder-slate-500"
/>
<span className="inline-block whitespace-nowrap rounded border border-slate-400/70 px-1.5 align-middle font-medium leading-4 tracking-wide text-slate-500 [font-size:10px] dark:border-slate-600 dark:text-slate-400">
ESC
</span>
</div>
<RenderResults />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
};
function RenderItem(props) {
const { item, active } = props;
return (
<div
className={
typeof item === "string"
? ""
: "hover:bg-gray-200 hover:dark:bg-gray-800"
}
>
{typeof item === "string" ? (
<div className="pt-3">
<div className="text-primary-600 block border-t border-gray-100 px-4 pt-6 pb-2 text-xs font-semibold uppercase dark:border-gray-800">
{item}
</div>
</div>
) : (
<div
className={`block cursor-pointer px-4 py-2 text-gray-600 dark:text-gray-200 ${
active ? "bg-primary-600" : "bg-transparent"
}`}
>
{item?.subtitle && (
<div
className={`${
active ? "text-gray-200" : "text-gray-400 dark:text-gray-500"
} text-xs`}
>
{item.subtitle}
</div>
)}
<div>{item?.name}</div>
</div>
)}
</div>
);
}
function RenderResults() {
const { results } = useMatches();
if (results.length) {
return <KBarResults items={results} onRender={RenderItem} />;
}
return (
<div className="block border-t border-gray-100 px-4 py-8 text-center text-gray-400 dark:border-gray-800 dark:text-gray-600">
No results for your search...
</div>
);
}

View File

@ -0,0 +1,48 @@
import { SearchIcon } from "../Icons";
// TODO types
export const SearchField: React.FC<any> = (props) => {
const { modifierKey, onOpen, mobile } = props;
return (
<button
type="button"
className={`
group flex h-6 w-6 items-center justify-center
${
mobile
? "sm:hidden justify-start min-w-full flex-none rounded-lg px-4 py-5 my-6 text-sm ring-1 ring-slate-200 dark:bg-slate-800/75 dark:ring-inset dark:ring-white/5"
: "hidden sm:flex sm:justify-start md:h-auto md:w-auto xl:w-full max-w-[380px] shrink xl:rounded-lg xl:py-2.5 xl:pl-4 xl:pr-3.5 md:text-sm xl:ring-1 xl:ring-slate-200 xl:hover:ring-slate-300 dark:xl:bg-slate-800/75 dark:xl:ring-inset dark:xl:ring-white/5 dark:xl:hover:bg-slate-700/40 dark:xl:hover:ring-slate-500"
}
`}
onClick={onOpen}
>
<SearchIcon className="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 dark:fill-slate-500 md:group-hover:fill-slate-400" />
<span
className={`
text-slate-500 dark:text-slate-400
${
mobile
? "w-full not-sr-only text-left ml-2"
: "hidden xl:block sr-only md:not-sr-only md:ml-2"
}
`}
>
Search
</span>
{modifierKey && (
<kbd
className={`
${
mobile
? "hidden"
: "ml-auto font-medium text-slate-400 dark:text-slate-500 hidden xl:block"
}
`}
>
<kbd className="font-sans">{modifierKey}</kbd>
<kbd className="font-sans">K</kbd>
</kbd>
)}
</button>
);
};

View File

@ -0,0 +1,69 @@
import dynamic from "next/dynamic.js";
import {
SearchProvider as SearchProviderType,
SearchProviderConfig,
} from "../types";
const AlgoliaSearchProvider = dynamic(
async () => {
return await import("./Algolia").then((mod) => mod.AlgoliaSearchProvider);
},
{ ssr: false }
);
const AlgoliaSearchContext = dynamic(
async () => {
return await import("./Algolia").then(
(mod) => mod.AlgoliaSearchContext.Consumer
);
},
{ ssr: false }
);
const KBarProvider = dynamic(
async () => {
return await import("./KBar").then((mod) => mod.KBarSearchProvider);
},
{ ssr: false }
);
const KBarSearchContext = dynamic(
async () => {
return await import("kbar").then((mod) => mod.KBarContext.Consumer);
},
{ ssr: false }
);
export const SearchProvider = ({
searchConfig,
children,
}: {
searchConfig: SearchProviderConfig;
children: React.ReactNode;
}) => {
switch (searchConfig?.provider) {
case "algolia":
return (
<AlgoliaSearchProvider config={searchConfig.config}>
{children}
</AlgoliaSearchProvider>
);
case "kbar":
return (
<KBarProvider config={searchConfig.config}>{children}</KBarProvider>
);
default:
return <>{children}</>;
}
};
export const SearchContext = (provider: SearchProviderType) => {
switch (provider) {
case "algolia":
return AlgoliaSearchContext;
case "kbar":
return KBarSearchContext;
default:
return undefined;
}
};

View File

@ -0,0 +1,3 @@
// TODO tidy up this API
export { SearchField } from "./SearchField";
export { SearchContext, SearchProvider } from "./SearchProvider";

View File

@ -0,0 +1,26 @@
// TODO don't import router here?
import router from "next/router.js";
import { Action } from "kbar";
import { formatDate } from "../../utils/formatDate";
import { nameFromUrl } from "../../utils/nameFromUrl";
// TODO temp type
type Document = any;
export const kbarActionsFromDocuments = (docs: Document[]): Action[] => {
const actions: Action[] = [];
for (const doc of docs) {
// excluding home path as this is defined in starting actions
doc.url_path &&
actions.push({
id: doc.url_path,
name: doc.title ?? nameFromUrl(doc.url_path),
keywords: doc.description ?? "",
section: doc.sourceDir ?? "Page",
subtitle: doc.date && formatDate(doc.date, "en-US"),
perform: () => router.push(`/${doc.url_path}`),
});
}
return actions;
};

View File

@ -0,0 +1,39 @@
import { forwardRef } from "react";
import clsx from "clsx";
const OuterContainer = forwardRef<
HTMLDivElement,
React.PropsWithChildren & { className?: string }
>(({ className, children, ...props }, ref) => {
return (
<div ref={ref} className={clsx("sm:px-8", className)} {...props}>
<div className="mx-auto max-w-5xl lg:px-8">{children}</div>
</div>
);
});
const InnerContainer = forwardRef<
HTMLDivElement,
React.PropsWithChildren & { className?: string }
>(({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={clsx("relative px-4 sm:px-8 lg:px-12", className)}
{...props}
>
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
</div>
);
});
export const Container = forwardRef<
HTMLDivElement,
React.PropsWithChildren & { className?: string }
>(({ children, ...props }, ref) => {
return (
<OuterContainer ref={ref} {...props}>
<InnerContainer>{children}</InnerContainer>
</OuterContainer>
);
});

View File

@ -0,0 +1,20 @@
/* eslint import/no-default-export: off */
import { Container } from "./Container";
// TODO types
export const SimpleLayout: React.FC<any> = ({ children, ...frontMatter }) => {
const { title, description } = frontMatter;
return (
<Container className="my-16 sm:mt-32">
<header className="max-w-2xl">
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
{title}
</h1>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
{description}
</p>
</header>
<div className="mt-16 sm:mt-20">{children}</div>
</Container>
);
};

View File

@ -0,0 +1 @@
export { SimpleLayout } from "./SimpleLayout";

View File

@ -0,0 +1,108 @@
import Link from "next/link.js";
import clsx from "clsx";
import { Disclosure, Transition } from "@headlessui/react";
export interface NavItem {
name: string;
href: string;
}
export interface NavGroup {
name: string;
path: string;
level: number;
children: Array<NavItem | NavGroup>;
}
interface Props {
currentPath: string;
nav: Array<NavItem | NavGroup>;
}
function isNavGroup(item: NavItem | NavGroup): item is NavGroup {
return (item as NavGroup).children !== undefined;
}
function navItemBeforeNavGroup(a, b) {
if (isNavGroup(a) === isNavGroup(b)) {
return 0;
}
if (isNavGroup(a) && !isNavGroup(b)) {
return 1;
}
return -1;
}
function sortNavGroupChildren(items: Array<NavItem | NavGroup>) {
return items.sort(
(a, b) => navItemBeforeNavGroup(a, b) || a.name.localeCompare(b.name)
);
}
export const SiteToc: React.FC<Props> = ({ currentPath, nav }) => {
function isActiveItem(item: NavItem) {
return item.href === currentPath;
}
return (
<nav data-testid="lhs-sidebar" className="flex flex-col space-y-3 text-sm">
{sortNavGroupChildren(nav).map((n) => (
<NavComponent item={n} isActive={false} />
))}
</nav>
);
};
const NavComponent: React.FC<{
item: NavItem | NavGroup;
isActive: boolean;
}> = ({ item, isActive }) => {
return !isNavGroup(item) ? (
<Link
key={item.name}
href={item.href}
className={clsx(
isActive
? "text-sky-500"
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300",
"block"
)}
>
{item.name}
</Link>
) : (
<Disclosure as="div" key={item.name} className="flex flex-col space-y-3">
{({ open }) => (
<div>
<Disclosure.Button className="group w-full flex items-center text-left text-md font-medium text-slate-900 dark:text-white">
<svg
className={clsx(
open ? "text-slate-400 rotate-90" : "text-slate-300",
"h-3 w-3 mr-2 flex-shrink-0 transform transition-colors duration-150 ease-in-out group-hover:text-slate-400"
)}
viewBox="0 0 20 20"
aria-hidden="true"
>
<path d="M6 6L14 10L6 14V6Z" fill="currentColor" />
</svg>
{item.name}
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel className="flex flex-col space-y-3 pl-5 mt-3">
{sortNavGroupChildren(item.children).map((subItem) => (
<NavComponent item={subItem} isActive={false} />
))}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
};

View File

@ -0,0 +1 @@
export { SiteToc, NavItem, NavGroup } from "./SiteToc";

View File

@ -0,0 +1,225 @@
import React, { useState, useEffect, useRef, Fragment } from "react";
// importing separately due to build error
// Module '"@floating-ui/react-dom-interactions"' has no exported member 'autoPlacement' ...
import {
arrow,
autoPlacement,
inline,
offset,
shift,
} from "@floating-ui/react-dom";
import {
FloatingPortal,
useDismiss,
useFloating,
useHover,
useFocus,
useInteractions,
useRole,
} from "@floating-ui/react-dom-interactions";
import { motion, AnimatePresence } from "framer-motion";
interface Props extends React.PropsWithChildren {
render: (t) => React.ReactNode;
href: string;
data: any;
usehook?: any;
className?: string;
}
const tooltipBoxStyle = (theme: string) =>
({
height: "auto",
maxWidth: "40rem",
padding: "1rem",
background: theme === "light" ? "#fff" : "#000",
color: theme === "light" ? "rgb(99, 98, 98)" : "#A8A8A8",
borderRadius: "4px",
boxShadow: "rgba(0, 0, 0, 0.55) 0px 0px 16px -3px",
} as React.CSSProperties);
const tooltipBodyStyle = (theme: string) =>
({
maxHeight: "4.8rem",
position: "relative",
lineHeight: "1.2rem",
overflow: "hidden",
} as React.CSSProperties);
const tooltipArrowStyle = ({ theme, x, y, side }) =>
({
position: "absolute",
left: x != null ? `${x}px` : "",
top: y != null ? `${y}px` : "",
right: "",
bottom: "",
[side]: "-4px",
height: "8px",
width: "8px",
background: theme === "light" ? "#fff" : "#000",
transform: "rotate(45deg)",
} as React.CSSProperties);
export const Tooltip: React.FC<Props> = ({
render,
data,
usehook,
...props
}) => {
const theme = "light"; // temporarily hard-coded; light theme tbd in next PR
const arrowRef = useRef(null);
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipData, setTooltipData] = useState({
content: null || <Fragment />,
image: "",
});
const [tooltipContentLoaded, setTooltipContentLoaded] = useState(false);
// floating-ui hook
const {
x,
y,
reference, // trigger element back ref
floating, // tooltip back ref
placement, // default: 'bottom'
strategy, // default: 'absolute'
context,
middlewareData: { arrow: { x: arrowX = 0, y: arrowY = 0 } = {} }, // data for arrow positioning
} = useFloating({
open: showTooltip, // state value binding
onOpenChange: setShowTooltip, // state value setter
middleware: [
offset(5), // offset from container border
autoPlacement({ padding: 5 }), // auto place vertically
shift({ padding: 5 }), // flip horizontally if necessary
arrow({ element: arrowRef, padding: 4 }), // add arrow element
inline(), // correct position for multiline anchor tags
],
});
// floating-ui hook
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context, { delay: 100 }),
useFocus(context),
useRole(context, { role: "tooltip" }),
useDismiss(context, { ancestorScroll: true }),
]);
const triggerElementProps = getReferenceProps({ ...props, ref: reference });
const tooltipProps = getFloatingProps({
ref: floating,
style: {
position: strategy,
left: x ?? "",
top: y ?? "",
},
});
const arrowPlacement = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[placement.split("-")[0]];
// get tooltip data
let image: string;
let PageContent;
const filePath = props.href.slice(1); // remove slash from the beginning
const page = data.find((p) => p._raw.flattenedPath === filePath);
if (page && page.body.code.length > 0) {
const Component = usehook(page.body.code);
PageContent = Component;
image = page.image ?? "";
}
const fetchTooltipContent = () => {
setTooltipContentLoaded(false);
let Body: React.ReactElement = <Fragment />;
// strip out all other elements from tooltip content
// since we only need the paragraph
const elems = ["h1", "h2", "h3", "div", "img", "pre", "blockquote"].reduce(
(acc, elem) => ({ ...acc, [elem]: () => <Fragment /> }),
{}
);
if (PageContent) {
Body = (
<PageContent
components={{
...elems,
p: (props) => <Fragment {...props} />, // avoid hydration errors
wrapper: (props) => <div className="line-clamp-3" {...props} />,
}}
/>
);
setTooltipData({
content: Body,
image: image,
});
setTooltipContentLoaded(true);
}
};
useEffect(() => {
if (showTooltip) {
fetchTooltipContent();
}
}, [showTooltip]);
return (
<Fragment>
{render?.(triggerElementProps)}
<FloatingPortal>
<AnimatePresence>
{showTooltip && tooltipContentLoaded && (
<motion.div
{...tooltipProps}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
>
<div
className="tooltip-box flex items-center space-x-2"
style={tooltipBoxStyle(theme)}
>
{tooltipData.image && (
<img
src={tooltipData.image}
alt=""
width={100}
height={100}
/>
)}
{tooltipData.content && (
<div className="tooltip-body" style={tooltipBodyStyle(theme)}>
{tooltipData.content}
</div>
)}
</div>
<div
ref={arrowRef}
className="tooltip-arrow"
style={tooltipArrowStyle({
theme,
x: arrowX,
y: arrowY,
side: arrowPlacement,
})}
></div>
</motion.div>
)}
</AnimatePresence>
</FloatingPortal>
</Fragment>
);
};

View File

@ -0,0 +1 @@
export { Tooltip } from "./Tooltip";

View File

@ -0,0 +1,6 @@
/* eslint import/no-default-export: off */
export const UnstyledLayout: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return <div className="unstyled dark:text-white">{children}</div>;
};

View File

@ -0,0 +1 @@
export { UnstyledLayout } from "./Unstyled";

View File

@ -0,0 +1,22 @@
export { ThemeProvider } from "next-themes";
export { Nav, NavConfig, ThemeConfig } from "./Nav";
export { SearchProvider } from "./Search";
export {
Layout,
TableOfContents,
TocSection,
EditThisPage,
useTableOfContents,
} from "./Layout";
export { Pre } from "./Pre";
export { CustomLink } from "./Base/CustomLink";
export { BlogsList } from "./Blog";
export { SimpleLayout } from "./SimpleLayout";
export { DocsLayout } from "./DocsLayout";
export { UnstyledLayout } from "./UnstyledLayout";
export { BlogLayout } from "./BlogLayout";
export { Mermaid } from "./Mermaid";
export { SiteToc, NavItem, NavGroup } from "./SiteToc";
export { Comments, CommentsConfig } from "./Comments";
export { AuthorConfig } from "./types";

View File

@ -0,0 +1,57 @@
// shared types used in more than one component
// TODO find out what's the best place to put them, what's the best practice
// layout
export interface NavLink {
name: string;
href: string;
}
export interface AuthorConfig {
name: string;
url: string;
logo: string;
}
// social
export type SocialPlatform = "github" | "discord";
export interface SocialLink {
label: SocialPlatform;
href: string;
}
// search
export type SearchProvider = "algolia" | "kbar";
export interface SearchProviderConfig {
provider: SearchProvider;
config: object;
}
// TEMP contentlayer
interface SharedFields {
title?: string;
description?: string;
image?: string;
layout: string;
showEditLink?: boolean;
showToc?: boolean;
showComments?: boolean;
isDraft?: boolean;
data: Array<string>;
}
interface ComputedFields {
urlPath: string;
editUrl?: string;
date?: string;
}
export interface Page extends SharedFields, ComputedFields {}
export interface Blog extends SharedFields, ComputedFields {
date: string; // TODO type?
authors?: Array<string>;
tags?: Array<string>;
}

View File

@ -0,0 +1,60 @@
// ToC: get the html nodelist for headings
import { TocSection } from "../ui/Layout";
export function collectHeadings(nodes: NodeListOf<HTMLHeadingElement>) {
const sections: Array<TocSection> = [];
Array.from(nodes).forEach((node) => {
const { id, innerText: title, tagName: level } = node;
if (!(id && title)) {
return;
}
if (level === "H1") {
sections.push({ id, title, level, children: [] });
}
const parentSection = sections[sections.length - 1];
if (level === "H2") {
if (parentSection && level > parentSection.level) {
(parentSection as TocSection).children.push({
id,
title,
level,
children: [],
});
} else {
sections.push({ id, title, level, children: [] });
}
}
if (level === "H3") {
const subSection =
parentSection?.children[parentSection?.children?.length - 1];
if (subSection && level > subSection.level) {
(subSection as TocSection).children.push({
id,
title,
level,
children: [],
});
} else if (parentSection && level > parentSection.level) {
(parentSection as TocSection).children.push({
id,
title,
level,
children: [],
});
} else {
sections.push({ id, title, level, children: [] });
}
}
// TODO types
sections.push(...collectHeadings((node.children as any) ?? []));
});
return sections;
}

View File

@ -0,0 +1,8 @@
export const formatDate = (date: string, locales = "en-US") => {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
return new Date(date).toLocaleDateString(locales, options);
};

View File

@ -0,0 +1,25 @@
// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
export const pageview = ({
url,
analyticsID,
}: {
url: string;
analyticsID: string;
}) => {
if (typeof window.gtag !== undefined) {
window.gtag("config", analyticsID, {
page_path: url,
});
}
};
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
export const event = ({ action, category, label, value }) => {
if (typeof window.gtag !== undefined) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
}
};

View File

@ -0,0 +1,2 @@
export { pageview } from "./gtag";
export { collectHeadings } from "./collectHeadings";

View File

@ -0,0 +1,4 @@
export const nameFromUrl = (url: string) => {
const name = url.split("/").slice(-1)[0].replace("-", " ");
return name.charAt(0).toUpperCase() + name.slice(1);
};

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"module": "es2020",
"moduleResolution": "node",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitAny": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"types": ["node"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"jest.config.ts",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}

View File

@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime"]
}

View File

@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,4 @@
extension: [ts]
node-option:
- experimental-specifier-resolution=node
- loader=ts-node/esm

View File

@ -0,0 +1,121 @@
# @portaljs/remark-callouts
Remark plugin to add support for blockquote-based callouts/admonitions similar to the approach of [Obsidian](https://help.obsidian.md/How+to/Use+callouts) and [Microsoft Learn](https://learn.microsoft.com/en-us/contribute/markdown-reference#alerts-note-tip-important-caution-warning) style.
Using this plugin, markdown like this:
```md
> [!tip]
> hello callout
```
Would render as a callout like this:
<img width="645" alt="Tip callout block" src="https://user-images.githubusercontent.com/42637597/193016397-49a90b44-cf3d-4eeb-9ad6-c0c1e374ed27.png">
## Features supported
- [x] Supports blockquote style callouts
- [x] Supports nested blockquote callouts
- [x] Supports 13 types out of the box (with appropriate styling in default theme) - see list below
- [x] Supports aliases for types
- [x] Defaults to note callout for all other types eg. `> [!xyz]`
- [x] Supports dark and light mode styles
Future support:
- [ ] Support custom types and icons
- [ ] Support custom aliases
- [ ] Support Foldable callouts
- [ ] Support custom styles
## Geting Started
### Installation
```bash
npm install remark-callouts
```
### Usage
```js
import callouts from "remark-callouts";
await remark()
.use(remarkParse)
.use(callouts)
.use(remarkRehype)
.use(rehypeStringify).process(`\
> [!tip]
> hello callout
`);
```
HTML output
```js
<div>
<blockquote class="callout">
<div class="callout-title tip">
<span class="callout-icon">
<svg>...</svg>
</span>
<strong>Tip</strong>
</div>
<div class="callout-content">
<p>hello callout</p>
</div>
</blockquote>
</div>
```
Import the styles in your .css file
```css
@import "remark-callouts/styles.css";
```
or in your app.js
```js
import "remark-callouts/styles.css";
```
### Supported Callout Types
- note
- tip `aliases: hint, important`
- warning `alises: caution, attention`
- abstract `aliases: summary, tldr`
- info
- todo
- success `aliases: check, done`
- question `aliases: help, faq`
- failure `aliases: fail, missing`
- danger `alias: error`
- bug
- example
- quote `alias: cite`
# Change Log
## [2.0.0] - 2022-11-21
### Added
- Classname for icon.
### Changed
- Extract css styles which can be imported separately.
## [1.0.2] - 2022-11-03
### Fixed
- Case insensitive match for types.
## License
MIT

View File

@ -0,0 +1,41 @@
{
"name": "@portaljs/remark-callouts",
"version": "1.0.0",
"description": "Remark plugin to add support for blockquote-based admonitions/callouts",
"repository": {
"type": "git",
"url": "git+https://github.com/datopian/portaljs.git",
"directory": "packages/remark-callouts"
},
"keywords": [
"remark",
"remark-plugin",
"markdown",
"admonitions",
"callouts",
"obsidian"
],
"author": "Rufus Pollock",
"license": "MIT",
"bugs": {
"url": "https://github.com/datopian/portaljs/issues"
},
"homepage": "https://github.com/datopian/portaljs#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"mdast-util-from-markdown": "^1.2.0",
"svg-parser": "^2.0.4",
"unist-util-visit": "^4.1.0"
},
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./styles.css": "./styles.css"
}
}

View File

@ -0,0 +1,48 @@
{
"name": "remark-callouts",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/remark-callouts/src",
"projectType": "library",
"targets": {
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["packages/remark-callouts/**/*.ts"]
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"command": "TS_NODE_PROJECT='packages/remark-callouts/tsconfig.spec.json' mocha --config packages/remark-callouts/.mocharc.yaml packages/remark-callouts/test/**"
}
},
"build": {
"executor": "@nrwl/rollup:rollup",
"outputs": ["{options.outputPath}"],
"options": {
"entryFile": "packages/remark-callouts/src/index.ts",
"outputPath": "packages/remark-callouts/dist",
"compiler": "babel",
"tsConfig": "packages/remark-callouts/tsconfig.lib.json",
"project": "packages/remark-callouts/package.json",
"format": ["esm", "cjs"],
"extractCss": true,
"generateExportsField": true,
"assets": [
{
"glob": "packages/remark-callouts/README.md",
"input": ".",
"output": "."
},
{
"glob": "packages/remark-callouts/styles.css",
"input": ".",
"output": "."
}
]
}
}
},
"tags": []
}

View File

@ -0,0 +1,2 @@
export * from "./lib/remark-callouts";
export { default } from "./lib/remark-callouts";

View File

@ -0,0 +1,83 @@
export const calloutTypes = {
// aliases
summary: "abstract",
tldr: "abstract",
hint: "tip",
important: "tip",
check: "success",
done: "success",
help: "question",
faq: "question",
caution: "warning",
attention: "warning",
fail: "failure",
missing: "failure",
error: "danger",
cite: "quote",
// base types
note: {
keyword: "note",
color: "#448aff",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-pencil"><line x1="18" y1="2" x2="22" y2="6"></line><path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path></svg>',
},
tip: {
keyword: "tip",
color: "#00bfa6",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-flame"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"></path></svg>',
},
warning: {
keyword: "warning",
color: "#ff9100",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-alert-triangle"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
},
abstract: {
keyword: "abstract",
color: "#00aeff",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-clipboard-list"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1z"></path><path d="M12 11h4"></path><path d="M12 16h4"></path><path d="M8 11h.01"></path><path d="M8 16h.01"></path></svg>',
},
info: {
keyword: "info",
color: "#00b8d4",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-info"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
},
todo: {
keyword: "todo",
color: "#00b8d4",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-check-circle-2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>',
},
success: {
keyword: "success",
color: "#00c853",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-check"><polyline points="20 6 9 17 4 12"></polyline></svg>',
},
question: {
keyword: "question",
color: "#63dd17",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
},
failure: {
keyword: "failure",
color: "#ff5252",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
},
danger: {
keyword: "danger",
color: "#ff1745",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-zap"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>',
},
bug: {
keyword: "bug",
color: "#f50057",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-bug"><rect x="8" y="6" width="8" height="14" rx="4"></rect><path d="m19 7-3 2"></path><path d="m5 7 3 2"></path><path d="m19 19-3-2"></path><path d="m5 19 3-2"></path><path d="M20 13h-4"></path><path d="M4 13h4"></path><path d="m10 4 1 2"></path><path d="m14 4-1 2"></path></svg>',
},
example: {
keyword: "example",
color: "#7c4dff",
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>',
},
quote: {
keyword: "quote",
color: "#9e9e9e",
svg: '<svg viewBox="0 0 100 100" class="quote-glyph" width="16" height="16"><path fill="currentColor" stroke="currentColor" d="M16.7,13.3c-3.7,0-6.7,3-6.7,6.7v26.7c0,3.7,3,6.7,6.7,6.7h13.5c0.1,6-0.5,18.7-6.3,28.2c0,0,0,0,0,0 c-0.9,1.4-0.7,3.1,0.5,4.2c1.2,1.1,3,1.2,4.3,0.2c0,0,14.7-11.2,14.7-32.7V20c0-3.7-3-6.7-6.7-6.7L16.7,13.3z M63.3,13.3 c-3.7,0-6.7,3-6.7,6.7v26.7c0,3.7,3,6.7,6.7,6.7h13.5c0.1,6-0.5,18.7-6.3,28.2h0c-0.9,1.4-0.7,3.1,0.5,4.2c1.2,1.1,3,1.2,4.3,0.2 c0,0,14.7-11.2,14.7-32.7V20c0-3.7-3-6.7-6.7-6.7L63.3,13.3z"></path></svg>',
},
};

View File

@ -0,0 +1,285 @@
import { visit } from "unist-util-visit";
import { fromMarkdown } from "mdast-util-from-markdown";
import type { Plugin } from "unified";
import type { Node, Data, Parent } from "unist";
import type { Blockquote, Heading, Text, BlockContent } from "mdast";
import { parse } from "svg-parser";
import { calloutTypes } from "./calloutTypes";
// escape regex special characters
function escapeRegExp(s: string) {
return s.replace(new RegExp(`[-[\\]{}()*+?.\\\\^$|/]`, "g"), "\\$&");
}
// match breaks
const find = /[\t ]*(?:\r?\n|\r)/g;
export const callouts: Plugin = function (providedConfig?: Partial<Config>) {
const config: Config = { ...defaultConfig, ...providedConfig };
const defaultKeywords: string = Object.keys(config.types)
.map(escapeRegExp)
.join("|");
return function (tree) {
visit(tree, (node: Node, index, parent: Parent<Node>) => {
// Filter required elems
if (node.type !== "blockquote") return;
/** add breaks to text without needing spaces or escapes (turns enters into <br>)
* code taken directly from remark-breaks,
* see https://github.com/remarkjs/remark-breaks for more info on what this does.
*/
visit(node, "text", (node: Text, index: number, parent: Parent) => {
const result = [];
let start = 0;
find.lastIndex = 0;
let match = find.exec(node.value);
while (match) {
const position = match.index;
if (start !== position) {
result.push({
type: "text",
value: node.value.slice(start, position),
});
}
result.push({ type: "break" });
start = position + match[0].length;
match = find.exec(node.value);
}
if (result.length > 0 && parent && typeof index === "number") {
if (start < node.value.length) {
result.push({ type: "text", value: node.value.slice(start) });
}
parent.children.splice(index, 1, ...result);
return index + result.length;
}
});
/** add classnames to headings within blockquotes,
* mainly to identify when using other plugins that
* might interfere. for eg, rehype-auto-link-headings.
*/
visit(node, "heading", (node) => {
const heading = node as Heading;
heading.data = {
hProperties: {
className: "blockquote-heading",
},
};
});
// wrap blockquote in a div
const wrapper = {
...node,
type: "element",
tagName: "div",
data: {
hProperties: {},
},
children: [node],
};
parent.children.splice(Number(index), 1, wrapper);
const blockquote = wrapper.children[0] as Blockquote;
blockquote.data = {
hProperties: {
className: "blockquote",
},
};
// check for callout syntax starts here
if (
blockquote.children.length <= 0 ||
blockquote.children[0].type !== "paragraph"
)
return;
const paragraph = blockquote.children[0];
if (
paragraph.children.length <= 0 ||
paragraph.children[0].type !== "text"
)
return;
const [t, ...rest] = paragraph.children;
const regex = new RegExp(
`^\\[!(?<keyword>(.*?))\\][\t\f ]?(?<title>.*?)$`,
"gi"
);
const m = regex.exec(t.value);
// if no callout syntax, forget about it.
if (!m) return;
const [key, title] = [m.groups?.keyword, m.groups?.title];
// if there's nothing inside the brackets, is it really a callout ?
if (!key) return;
const keyword = key.toLowerCase();
const isOneOfKeywords: boolean = new RegExp(defaultKeywords).test(
keyword
);
if (title) {
const mdast = fromMarkdown(title.trim()).children[0];
if (mdast.type === "heading") {
mdast.data = {
...mdast.data,
hProperties: {
className: "blockquote-heading",
},
};
}
blockquote.children.unshift(mdast as BlockContent);
} else {
t.value =
typeof keyword.charAt(0) === "string"
? keyword.charAt(0).toUpperCase() + keyword.slice(1)
: keyword;
}
const entry: { [index: string]: string } = {};
if (isOneOfKeywords) {
if (typeof config?.types[keyword] === "string") {
const e = String(config?.types[keyword]);
Object.assign(entry, config?.types[e]);
} else {
Object.assign(entry, config?.types[keyword]);
}
} else {
Object.assign(entry, config?.types["note"]);
}
let parsedSvg;
if (entry && entry.svg) {
parsedSvg = parse(entry.svg);
}
// create icon and title node wrapped in div
const titleNode: object = {
type: "element",
children: [
{
type: "element",
tagName: "span",
data: {
hName: "span",
hProperties: {
style: `color:${entry?.color}`,
className: "callout-icon",
},
hChildren: parsedSvg?.children ? parsedSvg.children : [],
},
},
{
type: "element",
children: title ? [blockquote.children[0]] : [t],
data: {
hName: "strong",
},
},
],
data: {
...blockquote.children[0]?.data,
hProperties: {
className: `${formatClassNameMap(config.classNameMaps.title)(
keyword
)} ${isOneOfKeywords ? keyword : "note"}`,
style: `background-color: ${entry?.color}1a;`,
},
},
};
// remove the callout paragraph from the content body
if (title) {
blockquote.children.shift();
}
if (rest.length > 0) {
rest.shift();
paragraph.children = rest;
} else {
blockquote.children.shift();
}
// wrap blockquote content in div
const contentNode: object = {
type: "element",
children: blockquote.children,
data: {
hProperties: {
className: "callout-content",
style:
parent.type !== "root"
? `border-right:1px solid ${entry?.color}33;
border-bottom:1px solid ${entry?.color}33;`
: "",
},
},
};
if (blockquote.children.length > 0)
blockquote.children = [contentNode] as BlockContent[];
blockquote.children.unshift(titleNode as BlockContent);
// Add classes for the callout block
blockquote.data = config.dataMaps.block({
...blockquote.data,
hProperties: {
className: formatClassNameMap(config.classNameMaps.block)(
keyword.toLowerCase()
),
style: `border-left-color:${entry?.color};`,
},
});
});
};
};
export interface Config {
classNameMaps: {
block: ClassNameMap;
title: ClassNameMap;
};
dataMaps: {
block: (data: Data) => Data;
title: (data: Data) => Data;
};
types: { [index: string]: string | object };
}
export const defaultConfig: Config = {
classNameMaps: {
block: "callout",
title: "callout-title",
},
dataMaps: {
block: (data) => data,
title: (data) => data,
},
types: { ...calloutTypes },
};
type ClassNames = string | string[];
type ClassNameMap = ClassNames | ((title: string) => ClassNames);
function formatClassNameMap(gen: ClassNameMap) {
return (title: string) => {
const classNames = typeof gen == "function" ? gen(title) : gen;
return typeof classNames == "object" ? classNames.join(" ") : classNames;
};
}
export default callouts;

View File

@ -0,0 +1,64 @@
:root {
--callout-bg-color: #f2f3f5;
}
:root.dark {
--callout-bg-color: #161616;
}
.blockquote,
.callout {
background: #f2f3f5;
background: var(--callout-bg-color);
font-style: normal;
border-radius: 2px;
}
.callout {
padding: 0 !important;
}
.callout-title {
display: flex;
align-items: center;
padding: 10px;
gap: 10px;
}
.callout-title > strong {
font-weight: 700;
}
.blockquote,
.callout-content {
padding: 10px 20px;
}
.blockquote-heading {
margin: 5px 0 !important;
padding: 0 !important;
}
.blockquote > p,
.callout-content > p {
font-weight: normal;
margin: 5px 0;
}
.callout-title p {
margin: 0;
}
.callout-title > strong {
line-height: 1.5;
}
.callout p:before,
p:after {
display: none;
}
.blockquote > p:before,
p:after {
display: none;
}

View File

@ -0,0 +1,143 @@
import { expect } from "chai";
import { parseDocument } from "htmlparser2";
import { selectOne } from "css-select";
import { remark } from "remark";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import { callouts, Config } from "../src";
async function mdToHtml(md: string, options?: Partial<Config>) {
return String(
await remark()
.use(remarkParse)
.use(callouts, options)
.use(remarkRehype)
.use(rehypeStringify)
.process(md)
);
}
describe("remark callouts", function () {
it("parses a blockquote without a callout", async function () {
const html = await mdToHtml(`\
> no callout
`);
const doc = parseDocument(html);
const blockquote = selectOne("div > blockquote.blockquote > p", doc);
expect(blockquote).to.have.nested.property("firstChild.data", "no callout");
});
it("parses a blockquote callout with title and content", async function () {
const html = await mdToHtml(`\
> [!tip]
> example content here
`);
const doc = parseDocument(html);
const calloutTitle = selectOne(
"div > blockquote.callout > div.callout-title.tip > strong",
doc
);
const calloutContent = selectOne(
"div > blockquote.callout > div.callout-content > p",
doc
);
expect(calloutTitle).to.have.nested.property("firstChild.data", "Tip");
expect(calloutContent).to.have.nested.property(
"firstChild.data",
"example content here"
);
});
it("parses a blockquote callout with case insensitive keyword", async function () {
const html = await mdToHtml(`\
> [!INFO]
`);
const doc = parseDocument(html);
const calloutTitle = selectOne(
"div > blockquote.callout > div.callout-title.info > strong",
doc
);
expect(calloutTitle).to.have.nested.property("firstChild.data", "Info");
});
it("parses a blockquote callout with an icon", async function () {
const html = await mdToHtml(`\
> [!tip]
> example content here
`);
const doc = parseDocument(html);
const calloutIcon = selectOne(
"div > blockquote.callout > div.callout-title.tip > span.callout-icon > svg",
doc
);
expect(calloutIcon).to.exist;
});
it("parses a blockquote callout with a custom title", async function () {
const html = await mdToHtml(`\
> [!tip] Custom Title
> content
`);
const doc = parseDocument(html);
const calloutTitle = selectOne(
"div > blockquote.callout > div.callout-title.tip > strong > p",
doc
);
expect(calloutTitle).to.have.nested.property(
"firstChild.data",
"Custom Title"
);
});
it("parses a blockquote callout with unknown type to use note", async function () {
const html = await mdToHtml(`\
> [!xyz]
> content
`);
const doc = parseDocument(html);
const calloutTitle = selectOne(
"div > blockquote.callout > div.callout-title.note > strong",
doc
);
expect(calloutTitle).to.have.nested.property("firstChild.data", "Xyz");
});
it("parses a blockquote callout with unknown type and custom title", async function () {
const html = await mdToHtml(`\
> [!xyz] Some title
> content
`);
const doc = parseDocument(html);
const calloutTitle = selectOne(
"div > blockquote.callout > div.callout-title.note > strong > p",
doc
);
expect(calloutTitle).to.have.nested.property(
"firstChild.data",
"Some title"
);
});
it("parses a nested blockquote with callout", async function () {
const html = await mdToHtml(`\
> [!note]
> content
> > [!info]
> > nested callout
`);
const doc = parseDocument(html);
const nestedCallout = selectOne(
"div > blockquote.callout > div.callout-content > div > blockquote.callout > div.callout-title > strong",
doc
);
expect(nestedCallout).to.have.nested.property("firstChild.data", "Info");
});
});

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"target": "es2020",
"module": "es2020",
"types": ["node"],
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"module": "es2020",
"moduleResolution": "node",
"types": ["mocha", "node"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}

View File

@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime"]
}

View File

@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,3 @@
# @portaljs/remark-embed
Converts Youtube link surrounded by newlines in markdown to embedded iframe

Some files were not shown because too many files have changed in this diff Show More