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:
parent
0b8c56bcac
commit
af134cac8b
6748
package-lock.json
generated
6748
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -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
12
packages/core/.babelrc
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
18
packages/core/.eslintrc.json
Normal file
18
packages/core/.eslintrc.json
Normal 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
3
packages/core/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# @portaljs/core
|
||||
|
||||
Core Portal.JS package containing components, styles, and utils.
|
||||
9
packages/core/jest.config.ts
Normal file
9
packages/core/jest.config.ts
Normal 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"]
|
||||
};
|
||||
42
packages/core/package.json
Normal file
42
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
packages/core/project.json
Normal file
45
packages/core/project.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/core/src/config/default.ts
Normal file
31
packages/core/src/config/default.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
1
packages/core/src/config/index.ts
Normal file
1
packages/core/src/config/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { defaultConfig } from "./default";
|
||||
3
packages/core/src/index.ts
Normal file
3
packages/core/src/index.ts
Normal 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
7
packages/core/src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag: any; // TODO
|
||||
}
|
||||
}
|
||||
24
packages/core/src/ui/Avatar/Avatar.tsx
Normal file
24
packages/core/src/ui/Avatar/Avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Avatar/index.ts
Normal file
1
packages/core/src/ui/Avatar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Avatar } from "./Avatar";
|
||||
15
packages/core/src/ui/Base/BaseLink.tsx
Normal file
15
packages/core/src/ui/Base/BaseLink.tsx
Normal 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 };
|
||||
57
packages/core/src/ui/Base/CustomLink.tsx
Normal file
57
packages/core/src/ui/Base/CustomLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
packages/core/src/ui/Base/ThemeSelector.tsx
Normal file
44
packages/core/src/ui/Base/ThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
105
packages/core/src/ui/Base/TwitterEmbed.tsx
Normal file
105
packages/core/src/ui/Base/TwitterEmbed.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
packages/core/src/ui/Base/index.ts
Normal file
3
packages/core/src/ui/Base/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { BaseLink } from "./BaseLink";
|
||||
export { ThemeSelector } from "./ThemeSelector";
|
||||
export { CustomLink } from "./CustomLink";
|
||||
21
packages/core/src/ui/Blog/Avatar.jsx
Normal file
21
packages/core/src/ui/Blog/Avatar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
packages/core/src/ui/Blog/BlogItem.tsx
Normal file
40
packages/core/src/ui/Blog/BlogItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
packages/core/src/ui/Blog/BlogsList.tsx
Normal file
36
packages/core/src/ui/Blog/BlogsList.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Blog/index.ts
Normal file
1
packages/core/src/ui/Blog/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BlogsList } from "./BlogsList";
|
||||
38
packages/core/src/ui/BlogLayout/BlogLayout.tsx
Normal file
38
packages/core/src/ui/BlogLayout/BlogLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/BlogLayout/index.ts
Normal file
1
packages/core/src/ui/BlogLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BlogLayout } from "./BlogLayout";
|
||||
170
packages/core/src/ui/Card/Card.tsx
Normal file
170
packages/core/src/ui/Card/Card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Card/index.ts
Normal file
1
packages/core/src/ui/Card/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Card } from "./Card";
|
||||
25
packages/core/src/ui/Comments/Disqus.tsx
Normal file
25
packages/core/src/ui/Comments/Disqus.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
53
packages/core/src/ui/Comments/Giscus.tsx
Normal file
53
packages/core/src/ui/Comments/Giscus.tsx
Normal 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"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
59
packages/core/src/ui/Comments/Utterances.tsx
Normal file
59
packages/core/src/ui/Comments/Utterances.tsx
Normal 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} />;
|
||||
};
|
||||
53
packages/core/src/ui/Comments/index.tsx
Normal file
53
packages/core/src/ui/Comments/index.tsx
Normal 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,
|
||||
};
|
||||
22
packages/core/src/ui/DocsLayout/Docs.tsx
Normal file
22
packages/core/src/ui/DocsLayout/Docs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/DocsLayout/index.ts
Normal file
1
packages/core/src/ui/DocsLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DocsLayout } from "./Docs";
|
||||
12
packages/core/src/ui/Icons/ChevronRightIcon.tsx
Normal file
12
packages/core/src/ui/Icons/ChevronRightIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/CloseIcon.tsx
Normal file
14
packages/core/src/ui/Icons/CloseIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/DiscordIcon.tsx
Normal file
14
packages/core/src/ui/Icons/DiscordIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
packages/core/src/ui/Icons/GitHubIcon.tsx
Normal file
7
packages/core/src/ui/Icons/GitHubIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/MenuIcon.tsx
Normal file
14
packages/core/src/ui/Icons/MenuIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
packages/core/src/ui/Icons/SearchIcon.tsx
Normal file
7
packages/core/src/ui/Icons/SearchIcon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
6
packages/core/src/ui/Icons/index.tsx
Normal file
6
packages/core/src/ui/Icons/index.tsx
Normal 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";
|
||||
30
packages/core/src/ui/Layout/EditThisPage.tsx
Normal file
30
packages/core/src/ui/Layout/EditThisPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
packages/core/src/ui/Layout/Footer.tsx
Normal file
61
packages/core/src/ui/Layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
147
packages/core/src/ui/Layout/Layout.tsx
Normal file
147
packages/core/src/ui/Layout/Layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
90
packages/core/src/ui/Layout/TableOfContents.tsx
Normal file
90
packages/core/src/ui/Layout/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
packages/core/src/ui/Layout/index.ts
Normal file
5
packages/core/src/ui/Layout/index.ts
Normal 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";
|
||||
51
packages/core/src/ui/Layout/useTableOfContents.ts
Normal file
51
packages/core/src/ui/Layout/useTableOfContents.ts
Normal 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;
|
||||
};
|
||||
25
packages/core/src/ui/Mermaid/Mermaid.tsx
Normal file
25
packages/core/src/ui/Mermaid/Mermaid.tsx
Normal 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} />;
|
||||
};
|
||||
1
packages/core/src/ui/Mermaid/index.ts
Normal file
1
packages/core/src/ui/Mermaid/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Mermaid } from "./Mermaid";
|
||||
86
packages/core/src/ui/Nav/Nav.tsx
Normal file
86
packages/core/src/ui/Nav/Nav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
packages/core/src/ui/Nav/NavItem.tsx
Normal file
21
packages/core/src/ui/Nav/NavItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
116
packages/core/src/ui/Nav/NavMobile.tsx
Normal file
116
packages/core/src/ui/Nav/NavMobile.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
27
packages/core/src/ui/Nav/NavSocial.tsx
Normal file
27
packages/core/src/ui/Nav/NavSocial.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
27
packages/core/src/ui/Nav/NavTitle.tsx
Normal file
27
packages/core/src/ui/Nav/NavTitle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Nav/index.ts
Normal file
1
packages/core/src/ui/Nav/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Nav, NavConfig, ThemeConfig } from "./Nav";
|
||||
74
packages/core/src/ui/Pre/Pre.tsx
Normal file
74
packages/core/src/ui/Pre/Pre.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Pre/index.ts
Normal file
1
packages/core/src/ui/Pre/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Pre } from "./Pre";
|
||||
132
packages/core/src/ui/Search/Algolia.tsx
Normal file
132
packages/core/src/ui/Search/Algolia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
packages/core/src/ui/Search/KBar.tsx
Normal file
33
packages/core/src/ui/Search/KBar.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
21
packages/core/src/ui/Search/KBarModal.tsx
Normal file
21
packages/core/src/ui/Search/KBarModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
120
packages/core/src/ui/Search/KBarPortal.tsx
Normal file
120
packages/core/src/ui/Search/KBarPortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
packages/core/src/ui/Search/SearchField.tsx
Normal file
48
packages/core/src/ui/Search/SearchField.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
packages/core/src/ui/Search/SearchProvider.tsx
Normal file
69
packages/core/src/ui/Search/SearchProvider.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
3
packages/core/src/ui/Search/index.ts
Normal file
3
packages/core/src/ui/Search/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// TODO tidy up this API
|
||||
export { SearchField } from "./SearchField";
|
||||
export { SearchContext, SearchProvider } from "./SearchProvider";
|
||||
26
packages/core/src/ui/Search/kbarActionsFromDocuments.ts
Normal file
26
packages/core/src/ui/Search/kbarActionsFromDocuments.ts
Normal 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;
|
||||
};
|
||||
39
packages/core/src/ui/SimpleLayout/Container.tsx
Normal file
39
packages/core/src/ui/SimpleLayout/Container.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
20
packages/core/src/ui/SimpleLayout/SimpleLayout.tsx
Normal file
20
packages/core/src/ui/SimpleLayout/SimpleLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/SimpleLayout/index.ts
Normal file
1
packages/core/src/ui/SimpleLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SimpleLayout } from "./SimpleLayout";
|
||||
108
packages/core/src/ui/SiteToc/SiteToc.tsx
Normal file
108
packages/core/src/ui/SiteToc/SiteToc.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/SiteToc/index.ts
Normal file
1
packages/core/src/ui/SiteToc/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SiteToc, NavItem, NavGroup } from "./SiteToc";
|
||||
225
packages/core/src/ui/Tooltip/Tooltip.tsx
Normal file
225
packages/core/src/ui/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Tooltip/index.ts
Normal file
1
packages/core/src/ui/Tooltip/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Tooltip } from "./Tooltip";
|
||||
6
packages/core/src/ui/UnstyledLayout/Unstyled.tsx
Normal file
6
packages/core/src/ui/UnstyledLayout/Unstyled.tsx
Normal 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>;
|
||||
};
|
||||
1
packages/core/src/ui/UnstyledLayout/index.ts
Normal file
1
packages/core/src/ui/UnstyledLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { UnstyledLayout } from "./Unstyled";
|
||||
22
packages/core/src/ui/index.ts
Normal file
22
packages/core/src/ui/index.ts
Normal 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";
|
||||
57
packages/core/src/ui/types.ts
Normal file
57
packages/core/src/ui/types.ts
Normal 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>;
|
||||
}
|
||||
60
packages/core/src/utils/collectHeadings.ts
Normal file
60
packages/core/src/utils/collectHeadings.ts
Normal 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;
|
||||
}
|
||||
8
packages/core/src/utils/formatDate.ts
Normal file
8
packages/core/src/utils/formatDate.ts
Normal 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);
|
||||
};
|
||||
25
packages/core/src/utils/gtag.ts
Normal file
25
packages/core/src/utils/gtag.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
2
packages/core/src/utils/index.ts
Normal file
2
packages/core/src/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { pageview } from "./gtag";
|
||||
export { collectHeadings } from "./collectHeadings";
|
||||
4
packages/core/src/utils/nameFromUrl.ts
Normal file
4
packages/core/src/utils/nameFromUrl.ts
Normal 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);
|
||||
};
|
||||
27
packages/core/tsconfig.json
Normal file
27
packages/core/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
packages/core/tsconfig.lib.json
Normal file
23
packages/core/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
20
packages/core/tsconfig.spec.json
Normal file
20
packages/core/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
4
packages/remark-callouts/.babelrc
Normal file
4
packages/remark-callouts/.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
18
packages/remark-callouts/.eslintrc.json
Normal file
18
packages/remark-callouts/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
packages/remark-callouts/.mocharc.yaml
Normal file
4
packages/remark-callouts/.mocharc.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
extension: [ts]
|
||||
node-option:
|
||||
- experimental-specifier-resolution=node
|
||||
- loader=ts-node/esm
|
||||
121
packages/remark-callouts/README.md
Normal file
121
packages/remark-callouts/README.md
Normal 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
|
||||
41
packages/remark-callouts/package.json
Normal file
41
packages/remark-callouts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
packages/remark-callouts/project.json
Normal file
48
packages/remark-callouts/project.json
Normal 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": []
|
||||
}
|
||||
2
packages/remark-callouts/src/index.ts
Normal file
2
packages/remark-callouts/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./lib/remark-callouts";
|
||||
export { default } from "./lib/remark-callouts";
|
||||
83
packages/remark-callouts/src/lib/calloutTypes.ts
Normal file
83
packages/remark-callouts/src/lib/calloutTypes.ts
Normal 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>',
|
||||
},
|
||||
};
|
||||
285
packages/remark-callouts/src/lib/remark-callouts.ts
Normal file
285
packages/remark-callouts/src/lib/remark-callouts.ts
Normal 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;
|
||||
64
packages/remark-callouts/styles.css
Normal file
64
packages/remark-callouts/styles.css
Normal 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;
|
||||
}
|
||||
143
packages/remark-callouts/test/remark-callouts.spec.ts
Normal file
143
packages/remark-callouts/test/remark-callouts.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
13
packages/remark-callouts/tsconfig.json
Normal file
13
packages/remark-callouts/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/remark-callouts/tsconfig.lib.json
Normal file
14
packages/remark-callouts/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
20
packages/remark-callouts/tsconfig.spec.json
Normal file
20
packages/remark-callouts/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
4
packages/remark-embed/.babelrc
Normal file
4
packages/remark-embed/.babelrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-transform-runtime"]
|
||||
}
|
||||
18
packages/remark-embed/.eslintrc.json
Normal file
18
packages/remark-embed/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/remark-embed/README.md
Normal file
3
packages/remark-embed/README.md
Normal 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
Loading…
x
Reference in New Issue
Block a user